Migrate Bubbles to wm-shell (5/n)

Move bubbles package and related resources to shell package,
also copied some used codes and resources.

Bug: 161980186
Test: atest SystemUITests
Test: atest WMShellUnitTests
Change-Id: Ia108bd4149b3c3bf86631ba1a7a6bce0e76af78f
diff --git a/libs/WindowManager/Shell/Android.bp b/libs/WindowManager/Shell/Android.bp
index 0defbd6..39e32c6 100644
--- a/libs/WindowManager/Shell/Android.bp
+++ b/libs/WindowManager/Shell/Android.bp
@@ -116,12 +116,15 @@
         "res",
     ],
     static_libs: [
+        "androidx.appcompat_appcompat",
+        "androidx.arch.core_core-runtime",
         "androidx.dynamicanimation_dynamicanimation",
         "kotlinx-coroutines-android",
         "kotlinx-coroutines-core",
+        "iconloader_base",
         "protolog-lib",
+        "SettingsLib",
         "WindowManager-Shell-proto",
-        "androidx.appcompat_appcompat",
     ],
     kotlincflags: ["-Xjvm-default=enable"],
     manifest: "AndroidManifest.xml",
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_dismiss_circle.xml b/libs/WindowManager/Shell/res/drawable/bubble_dismiss_circle.xml
new file mode 100644
index 0000000..2104be4
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_dismiss_circle.xml
@@ -0,0 +1,28 @@
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<!--
+    The transparent circle outline that encircles the bubbles when they're in the dismiss target.
+-->
+<shape
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+
+    <stroke
+        android:width="1dp"
+        android:color="#66FFFFFF" />
+
+    <solid android:color="#B3000000" />
+</shape>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_dismiss_icon.xml b/libs/WindowManager/Shell/res/drawable/bubble_dismiss_icon.xml
new file mode 100644
index 0000000..ff8fede
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_dismiss_icon.xml
@@ -0,0 +1,26 @@
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<!-- The 'X' bubble dismiss icon. This is just ic_close with a stroke. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24.0dp"
+        android:height="24.0dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:pathData="M19.000000,6.400000l-1.400000,-1.400000 -5.600000,5.600000 -5.600000,-5.600000 -1.400000,1.400000 5.600000,5.600000 -5.600000,5.600000 1.400000,1.400000 5.600000,-5.600000 5.600000,5.600000 1.400000,-1.400000 -5.600000,-5.600000z"
+        android:fillColor="#FFFFFFFF"
+        android:strokeColor="#FF000000"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_ic_create_bubble.xml b/libs/WindowManager/Shell/res/drawable/bubble_ic_create_bubble.xml
new file mode 100644
index 0000000..920671a2
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_ic_create_bubble.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="20dp"
+    android:height="20dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="M23,5v8h-2V5H3v14h10v2v0H3c-1.1,0 -2,-0.9 -2,-2V5c0,-1.1 0.9,-2 2,-2h18C22.1,3 23,3.9 23,5zM10,8v2.59L5.71,6.29L4.29,7.71L8.59,12H6v2h6V8H10zM19,15c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3S20.66,15 19,15z"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_dark.xml b/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_dark.xml
new file mode 100644
index 0000000..8f8f1b6
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_dark.xml
@@ -0,0 +1,162 @@
+<!--
+Copyright (C) 2020 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="412dp"
+    android:height="300dp"
+    android:viewportWidth="412"
+    android:viewportHeight="300">
+  <group>
+    <clip-path
+        android:pathData="M206,150m-150,0a150,150 0,1 1,300 0a150,150 0,1 1,-300 0"/>
+    <path
+        android:pathData="M296,105.2h-9.6l-3.1,-2.5l-3.1,2.5H116c-1.7,0 -3,1.3 -3,3v111.7c0,1.7 1.3,3 3,3h180c1.7,0 3,-1.3 3,-3V108.2C299,106.6 297.7,105.2 296,105.2C296,105.2 296,105.2 296,105.2z"
+        android:fillColor="#3C4043"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M252.4,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#D2E3FC"
+        android:strokeColor="#4285F4"/>
+    <path
+        android:pathData="M261.9,95.7m-4.5,0a4.5,4.5 0,1 1,9 0a4.5,4.5 0,1 1,-9 0"
+        android:fillColor="#4285F4"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M160.6,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#FAD2CF"
+        android:strokeColor="#EA4335"/>
+    <path
+        android:pathData="M170.1,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#EA4335"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M192.1,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#FEEFC3"
+        android:strokeColor="#FBBC04"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M221.8,85.4m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#CEEAD6"
+        android:strokeColor="#34A853"/>
+    <path
+        android:pathData="M201.6,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#FBBC04"/>
+    <path
+        android:pathData="M231.4,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#34A853"/>
+    <path
+        android:pathData="M282.8,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#3C4043"/>
+    <path
+        android:pathData="M278.7,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M282.8,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M286.9,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M129.2,72.9h-3.4l0.3,1c1,-0.3 2.1,-0.4 3.2,-0.4v-1V72.9zM122.6,74.8c-0.5,0.3 -1,0.6 -1.4,1l0,0l0,0l0,0l0,0h-0.6l0,0l0,0l0,0l0,0l0,0l0,0c-0.2,0.2 -0.3,0.3 -0.4,0.5l0.8,0.7c0.7,-0.8 1.5,-1.5 2.4,-2.1l-0.5,-0.8L122.6,74.8zM118,80L118,80L118,80L118,80L118,80L118,80L118,80L118,80c-0.5,1 -0.8,2 -1,3l1,0.2c0.2,-1 0.5,-2 1,-3L118,80zM117.8,86.7l-1,0.1c0.1,0.6 0.2,1.1 0.3,1.7l0,0l0,0h0.1l0,0l0,0l0,0l0,0c0.1,0.5 0.3,0.9 0.5,1.4l0.9,-0.4c-0.4,-1 -0.7,-2 -0.8,-3.1L117.8,86.7zM120.2,92.5l-0.8,0.6l0.2,0.3l0,0l0,0l0,0l0,0h0.3l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0.4,0.4l0,0l0,0l0,0l0,0h0.1l0,0l0,0l0,0l0,0l0,0h0.5l0,0l0,0l0,0l0,0l0,0l0,0l0.6,0.4l0.6,-0.8c-0.9,-0.6 -1.7,-1.4 -2.3,-2.2L120.2,92.5zM125.4,96.2l-0.3,0.9c1.1,0.4 2.2,0.6 3.4,0.7l0.1,-1C127.5,96.7 126.4,96.5 125.4,96.2zM134.7,95.4c-0.9,0.5 -2,0.9 -3,1.1l0.2,1h0.4c1,-0.3 2,-0.6 2.9,-1.2l-0.5,-0.9L134.7,95.4zM139.2,90.9c-0.5,0.9 -1.2,1.8 -1.9,2.5l0.7,0.7v-0.1h0.2l0,0l0,0c0.7,-0.7 1.3,-1.6 1.8,-2.4l-0.9,-0.5L139.2,90.9zM141.6,84.7h-1c0,0.2 0,0.4 0,0.6c0,0.9 -0.1,1.7 -0.3,2.6l1,0.2c0.1,-0.4 0.2,-0.8 0.2,-1.2l0,0v-0.1l0,0v-0.1l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0c0,-0.2 0,-0.3 0,-0.5l0,0L141.6,84.7zM139.3,78.2l-0.8,0.6c0.6,0.9 1.1,1.8 1.5,2.8l0.9,-0.3c-0.1,-0.2 -0.2,-0.4 -0.2,-0.7l0,0l0,0h-0.1l0,0l0,0l0,0l0,0l0,0l0,0c-0.3,-0.7 -0.7,-1.4 -1.1,-2l0,0l0,0l0,0l0,0l0,0l0,0l0,0L139.3,78.2zM134,73.9l-0.4,0.9c1,0.4 1.9,1 2.7,1.6l0.6,-0.8l0,0l0,0l0,0l0,0l0,0c-0.3,-0.3 -0.7,-0.5 -1,-0.7l0,0h-0.1h-0.6c-0.4,-0.2 -0.8,-0.4 -1.2,-0.6L134,73.9zM129.2,72.9v1c0.4,0 0.9,0 1.3,0.1l0.1,-1l-0.9,-0.1L129.2,72.9L129.2,72.9z"
+        android:fillColor="#34A853"/>
+    <path
+        android:pathData="M206,252m-11.7,0a11.7,11.7 0,1 1,23.4 0a11.7,11.7 0,1 1,-23.4 0"
+        android:fillColor="#F1F3F4"/>
+    <path
+        android:pathData="M201.7,247.7L210.3,256.3"
+        android:strokeWidth="2"
+        android:fillColor="#00000000"
+        android:strokeColor="#202124"
+        android:strokeLineCap="round"/>
+    <path
+        android:pathData="M210.3,247.7L201.7,256.3"
+        android:strokeWidth="2"
+        android:fillColor="#00000000"
+        android:strokeColor="#202124"
+        android:strokeLineCap="round"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M205.3,221.9m-10.4,0a10.4,10.4 0,1 1,20.8 0a10.4,10.4 0,1 1,-20.8 0"
+        android:fillColor="#CEEAD6"
+        android:strokeColor="#34A853"/>
+    <path
+        android:pathData="M481.4,292.2c48,58.3 119.8,125.8 58.6,162.9c-38.7,23.5 -53.9,24 -98.3,33.2c-43.8,9.1 -93.6,-89.8 -101.1,-134.5C329.6,288.6 452.6,257.2 481.4,292.2z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M458.2,320.7l-21.1,-71.4L400.5,193c-2.7,-5.1 -1.2,-11.4 3.5,-14.7l0,0c2.8,-2 6.6,-1.5 8.8,1.1c0,0 40.6,38.4 53.2,61.1l81.5,134.8l-69.9,-39.1L458.2,320.7z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M403.8,184.8l5.4,6.9c1.2,1.5 3.3,1.9 4.9,0.9l3,-1.8"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M420.9,325.8l-37.8,-88.6l-58.4,-37.8c-5.7,-5.4 -7.4,-13.8 -4.2,-21l0,0c2,-4.6 7.4,-6.7 12,-4.6c0.2,0.1 0.4,0.2 0.7,0.3c0,0 70.7,36.3 81.5,48.3l59.8,95.5l-49.9,24.9L420.9,325.8z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M324.6,183.9l8,6.2c2.1,1.7 5.2,1.4 7,-0.6l2.9,-3.3"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M392.4,231c3.8,-5.1 9.1,-8.9 15.1,-10.9"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M401.3,283.8L405.8,292.6"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M378.2,346.9l-34.7,-75.6l-60,-61.2c-6.3,-4.7 -9,-12.8 -6.7,-20.4l0,0c1.5,-4.8 6.5,-7.5 11.3,-6c0.2,0.1 0.4,0.1 0.7,0.2c0,0 73.5,48.2 82.6,61.7l64.1,95.7l-40.3,23.5L378.2,346.9z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M280.8,196.6l7.6,4.6c2.6,1.6 5.9,1.1 7.9,-1.1l4.1,-4.5"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M347.5,251c3.8,-5.1 9.1,-8.9 15.1,-10.9"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M207.2,234.1c-8.8,-11 4.7,-31.5 19.8,-19c17.7,14.7 74.7,64.3 74.7,64.3l103.8,101.8c0,0 -36.4,53.8 -44.5,42.3C287.8,319.3 234.4,267.9 207.2,234.1z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M209.6,226.2l9.3,9.5c1,0.8 3,0.4 3.1,-1c0.2,-2.2 4.6,-6.2 7,-6.6c1.1,-0.3 1.7,-1.4 1.4,-2.4c-0.1,-0.2 -0.2,-0.4 -0.3,-0.6l-4.4,-3.9"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M284.1,296.2c3.1,-5.5 7.8,-10 13.5,-12.8"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+  </group>
+  <path
+      android:pathData="M206,4c80.6,0 146,65.4 146,146c0,38.7 -15.4,75.9 -42.8,103.2c-57,57 -149.5,57 -206.5,0s-57,-149.5 0,-206.5C130.1,19.3 167.3,3.9 206,4M206,0C123.2,0 56,67.2 56,150s67.2,150 150,150s150,-67.2 150,-150S288.8,0 206,0z"
+      android:fillColor="#D2E3FC"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_light.xml b/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_light.xml
new file mode 100644
index 0000000..5e02f67
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_ic_empty_overflow_light.xml
@@ -0,0 +1,162 @@
+<!--
+Copyright (C) 2020 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="412dp"
+    android:height="300dp"
+    android:viewportWidth="412"
+    android:viewportHeight="300">
+  <group>
+    <clip-path
+        android:pathData="M206,150m-150,0a150,150 0,1 1,300 0a150,150 0,1 1,-300 0"/>
+    <path
+        android:pathData="M296,105.2h-9.6l-3.1,-2.5l-3.1,2.5H116c-1.7,0 -3,1.3 -3,3v111.7c0,1.7 1.3,3 3,3h180c1.7,0 3,-1.3 3,-3V108.2C299,106.6 297.7,105.2 296,105.2L296,105.2z"
+        android:fillColor="#F1F3F4"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M252.4,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#D2E3FC"
+        android:strokeColor="#4285F4"/>
+    <path
+        android:pathData="M261.9,95.7m-4.5,0a4.5,4.5 0,1 1,9 0a4.5,4.5 0,1 1,-9 0"
+        android:fillColor="#4285F4"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M160.6,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#FAD2CF"
+        android:strokeColor="#EA4335"/>
+    <path
+        android:pathData="M170.1,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#EA4335"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M192.1,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#FEEFC3"
+        android:strokeColor="#FBBC04"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M221.8,85.4m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#CEEAD6"
+        android:strokeColor="#34A853"/>
+    <path
+        android:pathData="M201.6,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#FBBC04"/>
+    <path
+        android:pathData="M231.4,95.7m-4.6,0a4.6,4.6 0,1 1,9.2 0a4.6,4.6 0,1 1,-9.2 0"
+        android:fillColor="#34A853"/>
+    <path
+        android:pathData="M282.8,85.3m-12.4,0a12.4,12.4 0,1 1,24.8 0a12.4,12.4 0,1 1,-24.8 0"
+        android:fillColor="#F1F3F4"/>
+    <path
+        android:pathData="M278.7,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#3474E0"/>
+    <path
+        android:pathData="M282.8,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#3474E0"/>
+    <path
+        android:pathData="M286.9,85.7m-1.2,0a1.2,1.2 0,1 1,2.4 0a1.2,1.2 0,1 1,-2.4 0"
+        android:fillColor="#3474E0"/>
+    <path
+        android:pathData="M129.2,72.9h-3.4l0.3,1c1,-0.3 2.1,-0.4 3.2,-0.4v-1v0.4H129.2zM122.6,74.8c-0.5,0.3 -1,0.6 -1.4,1l0,0l0,0l0,0l0,0h-0.6l0,0l0,0l0,0l0,0l0,0l0,0c-0.2,0.2 -0.3,0.3 -0.4,0.5L121,77c0.7,-0.8 1.5,-1.5 2.4,-2.1l-0.5,-0.8L122.6,74.8zM118,80L118,80L118,80L118,80L118,80L118,80L118,80L118,80c-0.5,1 -0.8,2 -1,3l1,0.2c0.2,-1 0.5,-2 1,-3L118,80zM117.8,86.7l-1,0.1c0.1,0.6 0.2,1.1 0.3,1.7l0,0l0,0h0.1l0,0l0,0l0,0l0,0c0.1,0.5 0.3,0.9 0.5,1.4l0.9,-0.4c-0.4,-1 -0.7,-2 -0.8,-3.1V86.7zM120.2,92.5l-0.8,0.6l0.2,0.3l0,0l0,0l0,0l0,0h0.3l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0.4,0.4l0,0l0,0l0,0l0,0h0.1l0,0l0,0l0,0l0,0l0,0h0.5l0,0l0,0l0,0l0,0l0,0l0,0l0.6,0.4l0.6,-0.8c-0.9,-0.6 -1.7,-1.4 -2.3,-2.2L120.2,92.5zM125.4,96.2l-0.3,0.9c1.1,0.4 2.2,0.6 3.4,0.7l0.1,-1C127.5,96.7 126.4,96.5 125.4,96.2zM134.7,95.4c-0.9,0.5 -2,0.9 -3,1.1l0.2,1h0.4c1,-0.3 2,-0.6 2.9,-1.2L134.7,95.4L134.7,95.4zM139.2,90.9c-0.5,0.9 -1.2,1.8 -1.9,2.5l0.7,0.7V94h0.2l0,0l0,0c0.7,-0.7 1.3,-1.6 1.8,-2.4l-0.9,-0.5L139.2,90.9zM141.6,84.7h-1c0,0.2 0,0.4 0,0.6c0,0.9 -0.1,1.7 -0.3,2.6l1,0.2c0.1,-0.4 0.2,-0.8 0.2,-1.2l0,0v-0.1l0,0v-0.1l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0l0,0c0,-0.2 0,-0.3 0,-0.5l0,0L141.6,84.7zM139.3,78.2l-0.8,0.6c0.6,0.9 1.1,1.8 1.5,2.8l0.9,-0.3c-0.1,-0.2 -0.2,-0.4 -0.2,-0.7l0,0l0,0h-0.1l0,0l0,0l0,0l0,0l0,0l0,0c-0.3,-0.7 -0.7,-1.4 -1.1,-2l0,0l0,0l0,0l0,0l0,0l0,0l0,0L139.3,78.2zM134,73.9l-0.4,0.9c1,0.4 1.9,1 2.7,1.6l0.6,-0.8l0,0l0,0l0,0l0,0l0,0c-0.3,-0.3 -0.7,-0.5 -1,-0.7l0,0h-0.1h-0.6c-0.4,-0.2 -0.8,-0.4 -1.2,-0.6V73.9zM129.2,72.9v1c0.4,0 0.9,0 1.3,0.1l0.1,-1l-0.9,-0.1H129.2L129.2,72.9z"
+        android:fillColor="#34A853"/>
+    <path
+        android:pathData="M206,252m-11.7,0a11.7,11.7 0,1 1,23.4 0a11.7,11.7 0,1 1,-23.4 0"
+        android:fillColor="#9AA0A6"/>
+    <path
+        android:pathData="M201.7,247.7L210.3,256.3"
+        android:strokeWidth="2"
+        android:fillColor="#00000000"
+        android:strokeColor="#F1F3F4"
+        android:strokeLineCap="round"/>
+    <path
+        android:pathData="M210.3,247.7L201.7,256.3"
+        android:strokeWidth="2"
+        android:fillColor="#00000000"
+        android:strokeColor="#F1F3F4"
+        android:strokeLineCap="round"/>
+    <path
+        android:strokeWidth="1"
+        android:pathData="M205.3,221.9m-10.4,0a10.4,10.4 0,1 1,20.8 0a10.4,10.4 0,1 1,-20.8 0"
+        android:fillColor="#CEEAD6"
+        android:strokeColor="#34A853"/>
+    <path
+        android:pathData="M481.4,292.2c48,58.3 119.8,125.8 58.6,162.9c-38.7,23.5 -53.9,24 -98.3,33.2c-43.8,9.1 -93.6,-89.8 -101.1,-134.5C329.6,288.6 452.6,257.2 481.4,292.2z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M458.2,320.7l-21.1,-71.4L400.5,193c-2.7,-5.1 -1.2,-11.4 3.5,-14.7l0,0c2.8,-2 6.6,-1.5 8.8,1.1c0,0 40.6,38.4 53.2,61.1l81.5,134.8l-69.9,-39.1L458.2,320.7z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M403.8,184.8l5.4,6.9c1.2,1.5 3.3,1.9 4.9,0.9l3,-1.8"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M420.9,325.8l-37.8,-88.6l-58.4,-37.8c-5.7,-5.4 -7.4,-13.8 -4.2,-21l0,0c2,-4.6 7.4,-6.7 12,-4.6c0.2,0.1 0.4,0.2 0.7,0.3c0,0 70.7,36.3 81.5,48.3l59.8,95.5l-49.9,24.9L420.9,325.8z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M324.6,183.9l8,6.2c2.1,1.7 5.2,1.4 7,-0.6l2.9,-3.3"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M392.4,231c3.8,-5.1 9.1,-8.9 15.1,-10.9"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M401.3,283.8L405.8,292.6"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M378.2,346.9l-34.7,-75.6l-60,-61.2c-6.3,-4.7 -9,-12.8 -6.7,-20.4l0,0c1.5,-4.8 6.5,-7.5 11.3,-6c0.2,0.1 0.4,0.1 0.7,0.2c0,0 73.5,48.2 82.6,61.7l64.1,95.7l-40.3,23.5L378.2,346.9z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M280.8,196.6l7.6,4.6c2.6,1.6 5.9,1.1 7.9,-1.1l4.1,-4.5"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M347.5,251c3.8,-5.1 9.1,-8.9 15.1,-10.9"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+    <path
+        android:pathData="M207.2,234.1c-8.8,-11 4.7,-31.5 19.8,-19c17.7,14.7 74.7,64.3 74.7,64.3l103.8,101.8c0,0 -36.4,53.8 -44.5,42.3C287.8,319.3 234.4,267.9 207.2,234.1z"
+        android:fillColor="#D2E3FC"/>
+    <path
+        android:pathData="M209.6,226.2l9.3,9.5c1,0.8 3,0.4 3.1,-1c0.2,-2.2 4.6,-6.2 7,-6.6c1.1,-0.3 1.7,-1.4 1.4,-2.4c-0.1,-0.2 -0.2,-0.4 -0.3,-0.6l-4.4,-3.9"
+        android:strokeLineJoin="bevel"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"
+        android:strokeLineCap="square"/>
+    <path
+        android:pathData="M284.1,296.2c3.1,-5.5 7.8,-10 13.5,-12.8"
+        android:strokeWidth="1.75"
+        android:fillColor="#00000000"
+        android:strokeColor="#A0C2F9"/>
+  </group>
+  <path
+      android:pathData="M206,4c80.6,0 146,65.4 146,146c0,38.7 -15.4,75.9 -42.8,103.2c-57,57 -149.5,57 -206.5,0s-57,-149.5 0,-206.5C130.1,19.3 167.3,3.9 206,4M206,0C123.2,0 56,67.2 56,150s67.2,150 150,150s150,-67.2 150,-150S288.8,0 206,0z"
+      android:fillColor="#D2E3FC"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_ic_overflow_button.xml b/libs/WindowManager/Shell/res/drawable/bubble_ic_overflow_button.xml
new file mode 100644
index 0000000..3acebc1
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_ic_overflow_button.xml
@@ -0,0 +1,24 @@
+<!--
+Copyright (C) 2020 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+  android:viewportWidth="24"
+  android:viewportHeight="24"
+  android:width="24dp"
+  android:height="24dp">
+  <path
+      android:fillColor="#1A73E8"
+      android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_ic_stop_bubble.xml b/libs/WindowManager/Shell/res/drawable/bubble_ic_stop_bubble.xml
new file mode 100644
index 0000000..8609576
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_ic_stop_bubble.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="20dp"
+    android:height="20dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="M11.29,14.71L7,10.41V13H5V7h6v2H8.41l4.29,4.29L11.29,14.71zM21,3H3C1.9,3 1,3.9 1,5v14c0,1.1 0.9,2 2,2h10v0v-2H3V5h18v8h2V5C23,3.9 22.1,3 21,3zM19,15c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3S20.66,15 19,15z"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_manage_menu_row.xml b/libs/WindowManager/Shell/res/drawable/bubble_manage_menu_row.xml
new file mode 100644
index 0000000..c61ac1c
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_manage_menu_row.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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:state_pressed="true">
+        <ripple android:color="#99999999" />
+    </item>
+</selector>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg.xml b/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg.xml
new file mode 100644
index 0000000..4b9219c
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg.xml
@@ -0,0 +1,22 @@
+<!--
+  ~ Copyright (C) 2020 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"
+       android:shape="rectangle">
+    <solid android:color="?android:attr/colorAccent"/>
+    <corners
+        android:bottomRightRadius="360dp"
+        android:topRightRadius="360dp" />
+</shape>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg_rtl.xml b/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg_rtl.xml
new file mode 100644
index 0000000..c7baba1
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/bubble_stack_user_education_bg_rtl.xml
@@ -0,0 +1,22 @@
+<!--
+  ~ Copyright (C) 2020 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"
+       android:shape="rectangle">
+    <solid android:color="?android:attr/colorAccent"/>
+    <corners
+        android:bottomLeftRadius="360dp"
+        android:topLeftRadius="360dp" />
+</shape>
diff --git a/libs/WindowManager/Shell/res/drawable/ic_remove_no_shadow.xml b/libs/WindowManager/Shell/res/drawable/ic_remove_no_shadow.xml
new file mode 100644
index 0000000..265c501
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/ic_remove_no_shadow.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?android:attr/textColorPrimary" >
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M13.41,12l5.29-5.29c0.39-0.39,0.39-1.02,0-1.41c-0.39-0.39-1.02-0.39-1.41,0L12,10.59L6.71,
+        5.29c-0.39-0.39-1.02-0.39-1.41,0c-0.39,0.39-0.39,1.02,0,1.41L10.59,12l-5.29,5.29c-0.39,0.39-0.39,1.02,
+        0,1.41c0.39,0.39,1.02,0.39,1.41,0L12,13.41l5.29,5.29c0.39,0.39,1.02,0.39,1.41,0c0.39-0.39,0.39-1.02,0-1.41L13.41,12z"/>
+</vector>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/rounded_bg_full.xml b/libs/WindowManager/Shell/res/drawable/rounded_bg_full.xml
new file mode 100644
index 0000000..e957445
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/rounded_bg_full.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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"
+       android:shape="rectangle">
+    <solid android:color="?android:attr/colorBackgroundFloating" />
+    <corners
+        android:bottomLeftRadius="?android:attr/dialogCornerRadius"
+        android:topLeftRadius="?android:attr/dialogCornerRadius"
+        android:bottomRightRadius="?android:attr/dialogCornerRadius"
+        android:topRightRadius="?android:attr/dialogCornerRadius"
+        />
+</shape>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_dismiss_target.xml b/libs/WindowManager/Shell/res/layout/bubble_dismiss_target.xml
new file mode 100644
index 0000000..f5cd727
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_dismiss_target.xml
@@ -0,0 +1,49 @@
+<!--
+  ~ Copyright (C) 2019 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
+  -->
+<!-- Bubble dismiss target consisting of an X icon and the text 'Dismiss'. -->
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="@dimen/floating_dismiss_gradient_height"
+    android:layout_gravity="bottom|center_horizontal">
+
+    <FrameLayout
+        android:id="@+id/bubble_dismiss_circle"
+        android:layout_width="@dimen/bubble_dismiss_encircle_size"
+        android:layout_height="@dimen/bubble_dismiss_encircle_size"
+        android:layout_gravity="center"
+        android:background="@drawable/bubble_dismiss_circle" />
+
+    <LinearLayout
+        android:id="@+id/bubble_dismiss_icon_container"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:gravity="center"
+        android:paddingBottom="@dimen/bubble_dismiss_target_padding_y"
+        android:paddingTop="@dimen/bubble_dismiss_target_padding_y"
+        android:paddingLeft="@dimen/bubble_dismiss_target_padding_x"
+        android:paddingRight="@dimen/bubble_dismiss_target_padding_x"
+        android:clipChildren="false"
+        android:clipToPadding="false"
+        android:orientation="horizontal">
+
+        <ImageView
+            android:id="@+id/bubble_dismiss_close_icon"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@drawable/bubble_dismiss_icon" />
+    </LinearLayout>
+</FrameLayout>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/layout/bubble_expanded_view.xml b/libs/WindowManager/Shell/res/layout/bubble_expanded_view.xml
new file mode 100644
index 0000000..54b08c6
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_expanded_view.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<com.android.wm.shell.bubbles.BubbleExpandedView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_height="wrap_content"
+    android:layout_width="match_parent"
+    android:orientation="vertical"
+    android:id="@+id/bubble_expanded_view">
+
+    <View
+        android:id="@+id/pointer_view"
+        android:layout_width="@dimen/bubble_pointer_width"
+        android:layout_height="@dimen/bubble_pointer_height"
+    />
+
+    <com.android.wm.shell.common.AlphaOptimizedButton
+        style="@android:style/Widget.Material.Button.Borderless"
+        android:id="@+id/settings_button"
+        android:layout_gravity="start"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:focusable="true"
+        android:text="@string/manage_bubbles_text"
+        android:textColor="?android:attr/textColorPrimaryInverse"
+    />
+
+</com.android.wm.shell.bubbles.BubbleExpandedView>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_flyout.xml b/libs/WindowManager/Shell/res/layout/bubble_flyout.xml
new file mode 100644
index 0000000..7fdf290
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_flyout.xml
@@ -0,0 +1,66 @@
+<!--
+  ~ Copyright (C) 2019 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
+  -->
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <LinearLayout
+        android:id="@+id/bubble_flyout_text_container"
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        android:orientation="horizontal"
+        android:clipToPadding="false"
+        android:clipChildren="false"
+        android:paddingStart="@dimen/bubble_flyout_padding_x"
+        android:paddingEnd="@dimen/bubble_flyout_padding_x"
+        android:paddingTop="@dimen/bubble_flyout_padding_y"
+        android:paddingBottom="@dimen/bubble_flyout_padding_y"
+        android:translationZ="@dimen/bubble_flyout_elevation">
+
+        <ImageView
+            android:id="@+id/bubble_flyout_avatar"
+            android:layout_width="30dp"
+            android:layout_height="30dp"
+            android:layout_marginEnd="@dimen/bubble_flyout_avatar_message_space"
+            android:scaleType="centerInside"
+            android:src="@drawable/bubble_ic_create_bubble"/>
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/bubble_flyout_name"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
+                android:maxLines="1"
+                android:ellipsize="end"
+                android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
+
+            <TextView
+                android:id="@+id/bubble_flyout_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:fontFamily="@*android:string/config_bodyFontFamily"
+                android:maxLines="2"
+                android:ellipsize="end"
+                android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
+
+        </LinearLayout>
+
+    </LinearLayout>
+
+</merge>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/layout/bubble_manage_menu.xml b/libs/WindowManager/Shell/res/layout/bubble_manage_menu.xml
new file mode 100644
index 0000000..3a6aa80
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_manage_menu.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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
+  -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:background="@drawable/rounded_bg_full"
+    android:elevation="@dimen/bubble_manage_menu_elevation"
+    android:orientation="vertical">
+
+    <LinearLayout
+        android:id="@+id/bubble_manage_menu_dismiss_container"
+        android:background="@drawable/bubble_manage_menu_row"
+        android:layout_width="match_parent"
+        android:layout_height="48dp"
+        android:gravity="center_vertical"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:orientation="horizontal">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@drawable/ic_remove_no_shadow"
+            android:tint="@color/bubbles_icon_tint"/>
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="16dp"
+            android:textAppearance="@*android:style/TextAppearance.DeviceDefault"
+            android:text="@string/bubble_dismiss_text" />
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/bubble_manage_menu_dont_bubble_container"
+        android:background="@drawable/bubble_manage_menu_row"
+        android:layout_width="match_parent"
+        android:layout_height="48dp"
+        android:gravity="center_vertical"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:orientation="horizontal">
+
+        <ImageView
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@drawable/bubble_ic_stop_bubble"
+            android:tint="@color/bubbles_icon_tint"/>
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="16dp"
+            android:textAppearance="@*android:style/TextAppearance.DeviceDefault"
+            android:text="@string/bubbles_dont_bubble_conversation" />
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/bubble_manage_menu_settings_container"
+        android:background="@drawable/bubble_manage_menu_row"
+        android:layout_width="match_parent"
+        android:layout_height="48dp"
+        android:gravity="center_vertical"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:orientation="horizontal">
+
+        <ImageView
+            android:id="@+id/bubble_manage_menu_settings_icon"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:src="@drawable/ic_remove_no_shadow"/>
+
+        <TextView
+            android:id="@+id/bubble_manage_menu_settings_name"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="16dp"
+            android:textAppearance="@*android:style/TextAppearance.DeviceDefault" />
+
+    </LinearLayout>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/layout/bubble_menu_view.xml b/libs/WindowManager/Shell/res/layout/bubble_menu_view.xml
new file mode 100644
index 0000000..0c1d1a5
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_menu_view.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2019 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.
+  -->
+<com.android.wm.shell.bubbles.BubbleMenuView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_height="match_parent"
+    android:layout_width="match_parent"
+    android:background="#66000000"
+    android:visibility="gone"
+    android:id="@+id/bubble_menu_container">
+
+    <FrameLayout
+        android:layout_height="@dimen/individual_bubble_size"
+        android:layout_width="wrap_content"
+        android:background="#FFFFFF"
+        android:id="@+id/bubble_menu_view">
+
+        <ImageView
+            android:id="@*android:id/icon"
+            android:layout_width="@dimen/bubble_grid_item_icon_width"
+            android:layout_height="@dimen/bubble_grid_item_icon_height"
+            android:layout_marginTop="@dimen/bubble_grid_item_icon_top_margin"
+            android:layout_marginBottom="@dimen/bubble_grid_item_icon_bottom_margin"
+            android:layout_marginLeft="@dimen/bubble_grid_item_icon_side_margin"
+            android:layout_marginRight="@dimen/bubble_grid_item_icon_side_margin"
+            android:scaleType="centerInside"
+            android:tint="@color/bubbles_icon_tint"
+        />
+    </FrameLayout>
+</com.android.wm.shell.bubbles.BubbleMenuView>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_overflow_activity.xml b/libs/WindowManager/Shell/res/layout/bubble_overflow_activity.xml
new file mode 100644
index 0000000..3060619
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_overflow_activity.xml
@@ -0,0 +1,72 @@
+<!--
+  ~ Copyright (C) 2019 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
+  -->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/bubble_overflow_container"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:paddingTop="@dimen/bubble_overflow_padding"
+    android:paddingLeft="@dimen/bubble_overflow_padding"
+    android:paddingRight="@dimen/bubble_overflow_padding"
+    android:orientation="vertical"
+    android:layout_gravity="center_horizontal">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/bubble_overflow_recycler"
+        android:layout_gravity="center_horizontal"
+        android:nestedScrollingEnabled="false"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"/>
+
+    <LinearLayout
+        android:id="@+id/bubble_overflow_empty_state"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:paddingLeft="@dimen/bubble_overflow_empty_state_padding"
+        android:paddingRight="@dimen/bubble_overflow_empty_state_padding"
+        android:orientation="vertical"
+        android:gravity="center">
+
+        <ImageView
+            android:layout_width="@dimen/bubble_empty_overflow_image_height"
+            android:layout_height="@dimen/bubble_empty_overflow_image_height"
+            android:id="@+id/bubble_overflow_empty_state_image"
+            android:scaleType="fitCenter"
+            android:layout_gravity="center"/>
+
+        <TextView
+            android:id="@+id/bubble_overflow_empty_title"
+            android:text="@string/bubble_overflow_empty_title"
+            android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
+            android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"
+            android:textColor="?android:attr/textColorSecondary"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center"/>
+
+        <TextView
+            android:id="@+id/bubble_overflow_empty_subtitle"
+            android:fontFamily="@*android:string/config_bodyFontFamily"
+            android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"
+            android:textColor="?android:attr/textColorSecondary"
+            android:text="@string/bubble_overflow_empty_subtitle"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingBottom="@dimen/bubble_empty_overflow_subtitle_padding"
+            android:gravity="center"/>
+    </LinearLayout>
+</LinearLayout>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_overflow_button.xml b/libs/WindowManager/Shell/res/layout/bubble_overflow_button.xml
new file mode 100644
index 0000000..61000fe
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_overflow_button.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+       ~ Copyright (C) 2020 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
+  -->
+<com.android.wm.shell.bubbles.BadgedImageView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/bubble_overflow_button"
+    android:layout_width="@dimen/individual_bubble_size"
+    android:layout_height="@dimen/individual_bubble_size"
+    android:src="@drawable/bubble_ic_overflow_button"/>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_overflow_view.xml b/libs/WindowManager/Shell/res/layout/bubble_overflow_view.xml
new file mode 100644
index 0000000..c1f67bd
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_overflow_view.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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
+  -->
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/bubble_overflow_view"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:orientation="vertical">
+
+    <com.android.wm.shell.bubbles.BadgedImageView
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:id="@+id/bubble_view"
+        android:layout_gravity="center"
+        android:layout_width="@dimen/individual_bubble_size"
+        android:layout_height="@dimen/individual_bubble_size"/>
+
+    <TextView
+        android:id="@+id/bubble_view_name"
+        android:textAppearance="@*android:style/TextAppearance.DeviceDefault.ListItem"
+        android:textSize="13sp"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:maxLines="1"
+        android:lines="2"
+        android:ellipsize="end"
+        android:layout_gravity="center"
+        android:paddingTop="@dimen/bubble_overflow_text_padding"
+        android:gravity="center"/>
+</LinearLayout>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_stack_user_education.xml b/libs/WindowManager/Shell/res/layout/bubble_stack_user_education.xml
new file mode 100644
index 0000000..fe1ed4b
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_stack_user_education.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+<LinearLayout
+    android:id="@+id/stack_education_layout"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_height="wrap_content"
+    android:layout_width="wrap_content"
+    android:paddingTop="48dp"
+    android:paddingBottom="48dp"
+    android:paddingStart="@dimen/bubble_stack_user_education_side_inset"
+    android:paddingEnd="16dp"
+    android:layout_marginEnd="24dp"
+    android:orientation="vertical"
+    android:background="@drawable/bubble_stack_user_education_bg"
+    >
+    <TextView
+        android:id="@+id/stack_education_title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingBottom="16dp"
+        android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
+        android:maxLines="2"
+        android:ellipsize="end"
+        android:gravity="start"
+        android:textAlignment="viewStart"
+        android:text="@string/bubbles_user_education_title"
+        android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Headline"/>
+
+    <TextView
+        android:id="@+id/stack_education_description"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:ellipsize="end"
+        android:gravity="start"
+        android:textAlignment="viewStart"
+        android:text="@string/bubbles_user_education_description"
+        android:fontFamily="@*android:string/config_bodyFontFamily"
+        android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
+</LinearLayout>
diff --git a/libs/WindowManager/Shell/res/layout/bubble_view.xml b/libs/WindowManager/Shell/res/layout/bubble_view.xml
new file mode 100644
index 0000000..a28bd678
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_view.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2018 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
+  -->
+<com.android.wm.shell.bubbles.BadgedImageView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/bubble_view"
+    android:layout_width="@dimen/individual_bubble_size"
+    android:layout_height="@dimen/individual_bubble_size"/>
diff --git a/libs/WindowManager/Shell/res/layout/bubbles_manage_button_education.xml b/libs/WindowManager/Shell/res/layout/bubbles_manage_button_education.xml
new file mode 100644
index 0000000..8de06c7
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubbles_manage_button_education.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/manage_education_view"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:clickable="true"
+    android:paddingTop="28dp"
+    android:paddingBottom="16dp"
+    android:paddingStart="@dimen/bubble_expanded_view_padding"
+    android:paddingEnd="48dp"
+    android:layout_marginEnd="24dp"
+    android:orientation="vertical"
+    android:background="@drawable/bubble_stack_user_education_bg"
+    >
+
+    <TextView
+        android:id="@+id/user_education_title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingStart="16dp"
+        android:paddingBottom="16dp"
+        android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
+        android:maxLines="2"
+        android:ellipsize="end"
+        android:gravity="start"
+        android:textAlignment="viewStart"
+        android:text="@string/bubbles_user_education_manage_title"
+        android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Headline"/>
+
+    <TextView
+        android:id="@+id/user_education_description"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:paddingStart="16dp"
+        android:paddingBottom="24dp"
+        android:text="@string/bubbles_user_education_manage"
+        android:maxLines="2"
+        android:ellipsize="end"
+        android:gravity="start"
+        android:textAlignment="viewStart"
+        android:fontFamily="@*android:string/config_bodyFontFamily"
+        android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body2"/>
+
+    <LinearLayout
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        android:id="@+id/button_layout"
+        android:orientation="horizontal" >
+
+        <com.android.wm.shell.common.AlphaOptimizedButton
+            style="@android:style/Widget.Material.Button.Borderless"
+            android:id="@+id/manage"
+            android:layout_gravity="start"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:focusable="true"
+            android:clickable="false"
+            android:text="@string/manage_bubbles_text"
+            android:textColor="?android:attr/textColorPrimaryInverse"
+            />
+
+        <com.android.wm.shell.common.AlphaOptimizedButton
+            style="@android:style/Widget.Material.Button.Borderless"
+            android:id="@+id/got_it"
+            android:layout_gravity="start"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:focusable="true"
+            android:text="@string/bubbles_user_education_got_it"
+            android:textColor="?android:attr/textColorPrimaryInverse"
+            />
+    </LinearLayout>
+</LinearLayout>
diff --git a/libs/WindowManager/Shell/res/values-land/dimens.xml b/libs/WindowManager/Shell/res/values-land/dimens.xml
index 77a601d..aafba58 100644
--- a/libs/WindowManager/Shell/res/values-land/dimens.xml
+++ b/libs/WindowManager/Shell/res/values-land/dimens.xml
@@ -18,4 +18,8 @@
 <resources>
     <dimen name="docked_divider_handle_width">2dp</dimen>
     <dimen name="docked_divider_handle_height">16dp</dimen>
+
+    <!-- Padding between status bar and bubbles when displayed in expanded state, smaller
+     value in landscape since we have limited vertical space-->
+    <dimen name="bubble_padding_top">4dp</dimen>
 </resources>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/values-night/colors.xml b/libs/WindowManager/Shell/res/values-night/colors.xml
new file mode 100644
index 0000000..24b3640
--- /dev/null
+++ b/libs/WindowManager/Shell/res/values-night/colors.xml
@@ -0,0 +1,20 @@
+<!--
+  ~ Copyright (C) 2020 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>
+    <!-- Bubbles -->
+    <color name="bubbles_icon_tint">@color/GM2_grey_200</color>
+</resources>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/values/colors.xml b/libs/WindowManager/Shell/res/values/colors.xml
index cc3bf2a..1674d0b 100644
--- a/libs/WindowManager/Shell/res/values/colors.xml
+++ b/libs/WindowManager/Shell/res/values/colors.xml
@@ -25,4 +25,14 @@
 
     <!-- Background for the various drop targets when handling drag and drop. -->
     <color name="drop_outline_background">#330000FF</color>
+
+    <!-- Bubbles -->
+    <color name="bubbles_light">#FFFFFF</color>
+    <color name="bubbles_dark">@color/GM2_grey_800</color>
+    <color name="bubbles_icon_tint">@color/GM2_grey_700</color>
+
+    <!-- GM2 colors -->
+    <color name="GM2_grey_200">#E8EAED</color>
+    <color name="GM2_grey_700">#5F6368</color>
+    <color name="GM2_grey_800">#3C4043</color>
 </resources>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml
index b87a642..8a60aaf 100644
--- a/libs/WindowManager/Shell/res/values/dimen.xml
+++ b/libs/WindowManager/Shell/res/values/dimen.xml
@@ -75,4 +75,94 @@
 
     <!-- The amount to inset the drop target regions from the edge of the display -->
     <dimen name="drop_layout_display_margin">16dp</dimen>
+
+    <!-- The menu grid size for bubble menu. -->
+    <dimen name="bubble_grid_item_icon_width">20dp</dimen>
+    <dimen name="bubble_grid_item_icon_height">20dp</dimen>
+    <dimen name="bubble_grid_item_icon_top_margin">12dp</dimen>
+    <dimen name="bubble_grid_item_icon_bottom_margin">4dp</dimen>
+    <dimen name="bubble_grid_item_icon_side_margin">22dp</dimen>
+
+    <!-- How much each bubble is elevated. -->
+    <dimen name="bubble_elevation">1dp</dimen>
+    <!-- How much the bubble flyout text container is elevated. -->
+    <dimen name="bubble_flyout_elevation">4dp</dimen>
+    <!-- How much padding is around the left and right sides of the flyout text. -->
+    <dimen name="bubble_flyout_padding_x">12dp</dimen>
+    <!-- How much padding is around the top and bottom of the flyout text. -->
+    <dimen name="bubble_flyout_padding_y">10dp</dimen>
+    <!-- Size of the triangle that points from the flyout to the bubble stack. -->
+    <dimen name="bubble_flyout_pointer_size">6dp</dimen>
+    <!-- How much space to leave between the flyout (tip of the arrow) and the bubble stack. -->
+    <dimen name="bubble_flyout_space_from_bubble">8dp</dimen>
+    <!-- How much space to leave between the flyout text and the avatar displayed in the flyout. -->
+    <dimen name="bubble_flyout_avatar_message_space">6dp</dimen>
+    <!-- Padding between status bar and bubbles when displayed in expanded state -->
+    <dimen name="bubble_padding_top">16dp</dimen>
+    <!-- Size of individual bubbles. -->
+    <dimen name="individual_bubble_size">60dp</dimen>
+    <!-- Size of bubble bitmap. -->
+    <dimen name="bubble_bitmap_size">52dp</dimen>
+    <!-- Size of bubble icon bitmap. -->
+    <dimen name="bubble_overflow_icon_bitmap_size">24dp</dimen>
+    <!-- Extra padding added to the touchable rect for bubbles so they are easier to grab. -->
+    <dimen name="bubble_touch_padding">12dp</dimen>
+    <!-- Size of the circle around the bubbles when they're in the dismiss target. -->
+    <dimen name="bubble_dismiss_encircle_size">52dp</dimen>
+    <!-- Padding around the view displayed when the bubble is expanded -->
+    <dimen name="bubble_expanded_view_padding">4dp</dimen>
+    <!-- This should be at least the size of bubble_expanded_view_padding; it is used to include
+         a slight touch slop around the expanded view. -->
+    <dimen name="bubble_expanded_view_slop">8dp</dimen>
+    <!-- Default (and minimum) height of the expanded view shown when the bubble is expanded -->
+    <dimen name="bubble_expanded_default_height">180dp</dimen>
+    <!-- Default height of bubble overflow -->
+    <dimen name="bubble_overflow_height">480dp</dimen>
+    <!-- Bubble overflow padding when there are no bubbles  -->
+    <dimen name="bubble_overflow_empty_state_padding">16dp</dimen>
+    <!-- Padding of container for overflow bubbles -->
+    <dimen name="bubble_overflow_padding">15dp</dimen>
+    <!-- Padding of label for bubble overflow view -->
+    <dimen name="bubble_overflow_text_padding">7dp</dimen>
+    <!-- Height of bubble overflow empty state illustration -->
+    <dimen name="bubble_empty_overflow_image_height">200dp</dimen>
+    <!-- Padding of bubble overflow empty state subtitle -->
+    <dimen name="bubble_empty_overflow_subtitle_padding">50dp</dimen>
+    <!-- Height of the triangle that points to the expanded bubble -->
+    <dimen name="bubble_pointer_height">8dp</dimen>
+    <!-- Width of the triangle that points to the expanded bubble -->
+    <dimen name="bubble_pointer_width">12dp</dimen>
+    <!-- Extra padding around the dismiss target for bubbles -->
+    <dimen name="bubble_dismiss_slop">16dp</dimen>
+    <!-- Height of button allowing users to adjust settings for bubbles. -->
+    <dimen name="bubble_manage_button_height">48dp</dimen>
+    <!-- Max width of the message bubble-->
+    <dimen name="bubble_message_max_width">144dp</dimen>
+    <!-- Min width of the message bubble -->
+    <dimen name="bubble_message_min_width">32dp</dimen>
+    <!-- Interior padding of the message bubble -->
+    <dimen name="bubble_message_padding">4dp</dimen>
+    <!-- Offset between bubbles in their stacked position. -->
+    <dimen name="bubble_stack_offset">10dp</dimen>
+    <!-- Offset between stack y and animation y for bubble swap. -->
+    <dimen name="bubble_swap_animation_offset">15dp</dimen>
+    <!-- How far offscreen the bubble stack rests. Cuts off padding and part of icon bitmap. -->
+    <dimen name="bubble_stack_offscreen">9dp</dimen>
+    <!-- How far down the screen the stack starts. -->
+    <dimen name="bubble_stack_starting_offset_y">120dp</dimen>
+    <!-- Space between the pointer triangle and the bubble expanded view -->
+    <dimen name="bubble_pointer_margin">8dp</dimen>
+    <!-- Padding applied to the bubble dismiss target. Touches in this padding cause the bubbles to
+         snap to the dismiss target. -->
+    <dimen name="bubble_dismiss_target_padding_x">40dp</dimen>
+    <dimen name="bubble_dismiss_target_padding_y">20dp</dimen>
+    <dimen name="bubble_manage_menu_elevation">4dp</dimen>
+
+    <!-- Bubbles user education views -->
+    <dimen name="bubbles_manage_education_width">160dp</dimen>
+    <!-- The inset from the top bound of the manage button to place the user education. -->
+    <dimen name="bubbles_manage_education_top_inset">65dp</dimen>
+    <!-- Size of padding for the user education cling, this should at minimum be larger than
+        individual_bubble_size + some padding. -->
+    <dimen name="bubble_stack_user_education_side_inset">72dp</dimen>
 </resources>
diff --git a/libs/WindowManager/Shell/res/values/ids.xml b/libs/WindowManager/Shell/res/values/ids.xml
index fb89238..434a000 100644
--- a/libs/WindowManager/Shell/res/values/ids.xml
+++ b/libs/WindowManager/Shell/res/values/ids.xml
@@ -23,4 +23,21 @@
     <item type="id" name="action_move_tl_50" />
     <item type="id" name="action_move_tl_30" />
     <item type="id" name="action_move_rb_full" />
+
+    <!-- For saving PhysicsAnimationLayout animations/animators as view tags. -->
+    <item type="id" name="translation_x_dynamicanimation_tag"/>
+    <item type="id" name="translation_y_dynamicanimation_tag"/>
+    <item type="id" name="translation_z_dynamicanimation_tag"/>
+    <item type="id" name="alpha_dynamicanimation_tag"/>
+    <item type="id" name="scale_x_dynamicanimation_tag"/>
+    <item type="id" name="scale_y_dynamicanimation_tag"/>
+    <item type="id" name="physics_animator_tag"/>
+    <item type="id" name="target_animator_tag" />
+    <item type="id" name="reorder_animator_tag"/>
+
+    <!-- Accessibility actions for bubbles. -->
+    <item type="id" name="action_move_top_left"/>
+    <item type="id" name="action_move_top_right"/>
+    <item type="id" name="action_move_bottom_left"/>
+    <item type="id" name="action_move_bottom_right"/>
 </resources>
diff --git a/libs/WindowManager/Shell/res/values/integers.xml b/libs/WindowManager/Shell/res/values/integers.xml
new file mode 100644
index 0000000..583bf33
--- /dev/null
+++ b/libs/WindowManager/Shell/res/values/integers.xml
@@ -0,0 +1,25 @@
+<!--
+  ~ Copyright (C) 2020 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>
+    <!-- Maximum number of bubbles to render and animate at one time. While the animations used are
+         lightweight translation animations, this number can be reduced on lower end devices if any
+         performance issues arise. -->
+    <integer name="bubbles_max_rendered">5</integer>
+    <!-- Number of columns in bubble overflow. -->
+    <integer name="bubbles_overflow_columns">4</integer>
+    <!-- Maximum number of bubbles we allow in overflow before we dismiss the oldest one. -->
+    <integer name="bubbles_max_overflow">16</integer>
+</resources>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/values/strings.xml b/libs/WindowManager/Shell/res/values/strings.xml
index da5965d..30ef72c 100644
--- a/libs/WindowManager/Shell/res/values/strings.xml
+++ b/libs/WindowManager/Shell/res/values/strings.xml
@@ -97,4 +97,53 @@
     <string name="accessibility_action_start_one_handed">Start one-handed mode</string>
     <!-- Accessibility description for stop one-handed mode [CHAR LIMIT=NONE] -->
     <string name="accessibility_action_stop_one_handed">Exit one-handed mode</string>
+
+    <!-- Text used for content description of settings button in the header of expanded bubble
+         view. [CHAR_LIMIT=NONE] -->
+    <string name="bubbles_settings_button_description">Settings for <xliff:g id="app_name" example="YouTube">%1$s</xliff:g> bubbles</string>
+    <!-- Content description for button that shows bubble overflow on click [CHAR LIMIT=NONE] -->
+    <string name="bubble_overflow_button_content_description">Overflow</string>
+    <!-- Action to add overflow bubble back to stack. [CHAR LIMIT=NONE] -->
+    <string name="bubble_accessibility_action_add_back">Add back to stack</string>
+    <!-- Content description when a bubble is focused. [CHAR LIMIT=NONE] -->
+    <string name="bubble_content_description_single"><xliff:g id="notification_title" example="some title">%1$s</xliff:g> from <xliff:g id="app_name" example="YouTube">%2$s</xliff:g></string>
+    <!-- Content description when the stack of bubbles is focused. [CHAR LIMIT=NONE] -->
+    <string name="bubble_content_description_stack"><xliff:g id="notification_title" example="some title">%1$s</xliff:g> from <xliff:g id="app_name" example="YouTube">%2$s</xliff:g> and <xliff:g id="bubble_count" example="4">%3$d</xliff:g> more</string>
+    <!-- Action in accessibility menu to move the stack of bubbles to the top left of the screen. [CHAR LIMIT=30] -->
+    <string name="bubble_accessibility_action_move_top_left">Move top left</string>
+    <!-- Action in accessibility menu to move the stack of bubbles to the top right of the screen. [CHAR LIMIT=30] -->
+    <string name="bubble_accessibility_action_move_top_right">Move top right</string>
+    <!-- Action in accessibility menu to move the stack of bubbles to the bottom left of the screen. [CHAR LIMIT=30]-->
+    <string name="bubble_accessibility_action_move_bottom_left">Move bottom left</string>
+    <!-- Action in accessibility menu to move the stack of bubbles to the bottom right of the screen. [CHAR LIMIT=30]-->
+    <string name="bubble_accessibility_action_move_bottom_right">Move bottom right</string>
+    <!-- Label for the button that takes the user to the notification settings for the given app. -->
+    <string name="bubbles_app_settings"><xliff:g id="notification_title" example="Android Messages">%1$s</xliff:g> settings</string>
+    <!-- Text used for the bubble dismiss area. Bubbles dragged to, or flung towards, this area will go away. [CHAR LIMIT=30] -->
+    <string name="bubble_dismiss_text">Dismiss bubble</string>
+    <!-- Button text to stop a conversation from bubbling [CHAR LIMIT=60]-->
+    <string name="bubbles_dont_bubble_conversation">Don\u2019t bubble conversation</string>
+    <!-- Title text for the bubbles feature education cling shown when a bubble is on screen for the first time. [CHAR LIMIT=60]-->
+    <string name="bubbles_user_education_title">Chat using bubbles</string>
+    <!-- Descriptive text for the bubble feature education cling shown when a bubble is on screen for the first time. [CHAR LIMIT=NONE] -->
+    <string name="bubbles_user_education_description">New conversations appear as floating icons, or bubbles. Tap to open bubble. Drag to move it.</string>
+    <!-- Title text for the bubble "manage" button tool tip highlighting where users can go to control bubble settings. [CHAR LIMIT=60]-->
+    <string name="bubbles_user_education_manage_title">Control bubbles anytime</string>
+    <!-- Descriptive text for the bubble "manage" button tool tip highlighting where users can go to control bubble settings. [CHAR LIMIT=80]-->
+    <string name="bubbles_user_education_manage">Tap Manage to turn off bubbles from this app</string>
+    <!-- Button text for dismissing the bubble "manage" button tool tip  [CHAR LIMIT=20]-->
+    <string name="bubbles_user_education_got_it">Got it</string>
+    <!-- [CHAR LIMIT=NONE] Empty overflow title -->
+    <string name="bubble_overflow_empty_title">No recent bubbles</string>
+    <!-- [CHAR LIMIT=NONE] Empty overflow subtitle -->
+    <string name="bubble_overflow_empty_subtitle">Recent bubbles and dismissed bubbles will appear here</string>
+
+    <!-- [CHAR LIMIT=100] Notification Importance title -->
+    <string name="notification_bubble_title">Bubble</string>
+
+    <!-- The text for the manage bubbles link. [CHAR LIMIT=NONE] -->
+    <string name="manage_bubbles_text">Manage</string>
+
+    <!-- Content description to tell the user a bubble has been dismissed. -->
+    <string name="accessibility_bubble_dismissed">Bubble dismissed.</string>
 </resources>
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/TaskView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/TaskView.java
new file mode 100644
index 0000000..59a765d
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/TaskView.java
@@ -0,0 +1,341 @@
+/*
+ * Copyright (C) 2020 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.wm.shell;
+
+import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.LauncherApps;
+import android.content.pm.ShortcutInfo;
+import android.graphics.Rect;
+import android.os.Binder;
+import android.util.CloseGuard;
+import android.view.SurfaceControl;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
+
+import java.io.PrintWriter;
+import java.util.concurrent.Executor;
+
+/**
+ * View that can display a task.
+ */
+public class TaskView extends SurfaceView implements SurfaceHolder.Callback,
+        ShellTaskOrganizer.TaskListener {
+
+    /** Callback for listening task state. */
+    public interface Listener {
+        /** Called when the container is ready for launching activities. */
+        default void onInitialized() {}
+
+        /** Called when the container can no longer launch activities. */
+        default void onReleased() {}
+
+        /** Called when a task is created inside the container. */
+        default void onTaskCreated(int taskId, ComponentName name) {}
+
+        /** Called when a task visibility changes. */
+        default void onTaskVisibilityChanged(int taskId, boolean visible) {}
+
+        /** Called when a task is about to be removed from the stack inside the container. */
+        default void onTaskRemovalStarted(int taskId) {}
+
+        /** Called when a task is created inside the container. */
+        default void onBackPressedOnTaskRoot(int taskId) {}
+    }
+
+    private final CloseGuard mGuard = new CloseGuard();
+
+    private final ShellTaskOrganizer mTaskOrganizer;
+
+    private ActivityManager.RunningTaskInfo mTaskInfo;
+    private WindowContainerToken mTaskToken;
+    private SurfaceControl mTaskLeash;
+    private final SurfaceControl.Transaction mTransaction = new SurfaceControl.Transaction();
+    private boolean mSurfaceCreated;
+    private boolean mIsInitialized;
+    private Listener mListener;
+    private Executor mExecutor;
+
+    private final Rect mTmpRect = new Rect();
+    private final Rect mTmpRootRect = new Rect();
+
+    public TaskView(Context context, ShellTaskOrganizer organizer) {
+        super(context, null, 0, 0, true /* disableBackgroundLayer */);
+
+        mTaskOrganizer = organizer;
+        setUseAlpha();
+        getHolder().addCallback(this);
+        mGuard.open("release");
+    }
+
+    // TODO: Use TaskOrganizer executor when part of wmshell proper
+    public void setExecutor(Executor executor) {
+        mExecutor = executor;
+    }
+
+    /**
+     * Only one listener may be set on the view, throws an exception otherwise.
+     */
+    public void setListener(Listener listener) {
+        if (mListener != null) {
+            throw new IllegalStateException(
+                    "Trying to set a listener when one has already been set");
+        }
+        mListener = listener;
+    }
+
+    /**
+     * Launch an activity represented by {@link ShortcutInfo}.
+     * <p>The owner of this container must be allowed to access the shortcut information,
+     * as defined in {@link LauncherApps#hasShortcutHostPermission()} to use this method.
+     *
+     * @param shortcut the shortcut used to launch the activity.
+     * @param options options for the activity.
+     * @param sourceBounds the rect containing the source bounds of the clicked icon to open
+     *                     this shortcut.
+     */
+    public void startShortcutActivity(@NonNull ShortcutInfo shortcut,
+            @NonNull ActivityOptions options, @Nullable Rect sourceBounds) {
+        prepareActivityOptions(options);
+        LauncherApps service = mContext.getSystemService(LauncherApps.class);
+        try {
+            service.startShortcut(shortcut, sourceBounds, options.toBundle());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Launch a new activity.
+     *
+     * @param pendingIntent Intent used to launch an activity.
+     * @param fillInIntent Additional Intent data, see {@link Intent#fillIn Intent.fillIn()}
+     * @param options options for the activity.
+     */
+    public void startActivity(@NonNull PendingIntent pendingIntent, @Nullable Intent fillInIntent,
+            @NonNull ActivityOptions options) {
+        prepareActivityOptions(options);
+        try {
+            pendingIntent.send(mContext, 0 /* code */, fillInIntent,
+                    null /* onFinished */, null /* handler */, null /* requiredPermission */,
+                    options.toBundle());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void prepareActivityOptions(ActivityOptions options) {
+        final Binder launchCookie = new Binder();
+        mTaskOrganizer.setPendingLaunchCookieListener(launchCookie, this);
+        options.setLaunchCookie(launchCookie);
+        options.setLaunchWindowingMode(WINDOWING_MODE_MULTI_WINDOW);
+        options.setTaskAlwaysOnTop(true);
+    }
+
+    /**
+     * Call when view position or size has changed. Do not call when animating.
+     */
+    public void onLocationChanged() {
+        if (mTaskToken == null) {
+            return;
+        }
+        // Update based on the screen bounds
+        getBoundsOnScreen(mTmpRect);
+        getRootView().getBoundsOnScreen(mTmpRootRect);
+        if (!mTmpRootRect.contains(mTmpRect)) {
+            mTmpRect.offsetTo(0, 0);
+        }
+
+        WindowContainerTransaction wct = new WindowContainerTransaction();
+        wct.setBounds(mTaskToken, mTmpRect);
+        // TODO(b/151449487): Enable synchronization
+        mTaskOrganizer.applyTransaction(wct);
+    }
+
+    /**
+     * Release this container if it is initialized.
+     */
+    public void release() {
+        performRelease();
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+        try {
+            if (mGuard != null) {
+                mGuard.warnIfOpen();
+                performRelease();
+            }
+        } finally {
+            super.finalize();
+        }
+    }
+
+    private void performRelease() {
+        getHolder().removeCallback(this);
+        mTaskOrganizer.removeListener(this);
+        resetTaskInfo();
+        mGuard.close();
+        if (mListener != null && mIsInitialized) {
+            mListener.onReleased();
+            mIsInitialized = false;
+        }
+    }
+
+    private void resetTaskInfo() {
+        mTaskInfo = null;
+        mTaskToken = null;
+        mTaskLeash = null;
+    }
+
+    private void updateTaskVisibility() {
+        WindowContainerTransaction wct = new WindowContainerTransaction();
+        wct.setHidden(mTaskToken, !mSurfaceCreated /* hidden */);
+        mTaskOrganizer.applyTransaction(wct);
+        // TODO(b/151449487): Only call callback once we enable synchronization
+        if (mListener != null) {
+            mListener.onTaskVisibilityChanged(mTaskInfo.taskId, mSurfaceCreated);
+        }
+    }
+
+    @Override
+    public void onTaskAppeared(ActivityManager.RunningTaskInfo taskInfo,
+            SurfaceControl leash) {
+        if (mExecutor == null) return;
+        mExecutor.execute(() -> {
+            mTaskInfo = taskInfo;
+            mTaskToken = taskInfo.token;
+            mTaskLeash = leash;
+
+            if (mSurfaceCreated) {
+                // Surface is ready, so just reparent the task to this surface control
+                mTransaction.reparent(mTaskLeash, getSurfaceControl())
+                        .show(mTaskLeash)
+                        .apply();
+            } else {
+                // The surface has already been destroyed before the task has appeared,
+                // so go ahead and hide the task entirely
+                updateTaskVisibility();
+            }
+
+            // TODO: Synchronize show with the resize
+            onLocationChanged();
+            setResizeBackgroundColor(taskInfo.taskDescription.getBackgroundColor());
+
+            if (mListener != null) {
+                mListener.onTaskCreated(taskInfo.taskId, taskInfo.baseActivity);
+            }
+        });
+    }
+
+    @Override
+    public void onTaskVanished(ActivityManager.RunningTaskInfo taskInfo) {
+        if (mExecutor == null) return;
+        mExecutor.execute(() -> {
+            if (mTaskToken == null || !mTaskToken.equals(taskInfo.token)) return;
+
+            if (mListener != null) {
+                mListener.onTaskRemovalStarted(taskInfo.taskId);
+            }
+
+            // Unparent the task when this surface is destroyed
+            mTransaction.reparent(mTaskLeash, null).apply();
+            resetTaskInfo();
+        });
+    }
+
+    @Override
+    public void onTaskInfoChanged(ActivityManager.RunningTaskInfo taskInfo) {
+        if (mExecutor == null) return;
+        mExecutor.execute(() -> {
+            mTaskInfo.taskDescription = taskInfo.taskDescription;
+            setResizeBackgroundColor(taskInfo.taskDescription.getBackgroundColor());
+        });
+    }
+
+    @Override
+    public void onBackPressedOnTaskRoot(ActivityManager.RunningTaskInfo taskInfo) {
+        if (mExecutor == null) return;
+        mExecutor.execute(() -> {
+            if (mTaskToken == null || !mTaskToken.equals(taskInfo.token)) return;
+            if (mListener != null) {
+                mListener.onBackPressedOnTaskRoot(taskInfo.taskId);
+            }
+        });
+    }
+
+    @Override
+    public void dump(@androidx.annotation.NonNull PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        final String childPrefix = innerPrefix + "  ";
+        pw.println(prefix + this);
+    }
+
+    @Override
+    public String toString() {
+        return "TaskView" + ":" + (mTaskInfo != null ? mTaskInfo.taskId : "null");
+    }
+
+    @Override
+    public void surfaceCreated(SurfaceHolder holder) {
+        mSurfaceCreated = true;
+        if (mListener != null && !mIsInitialized) {
+            mIsInitialized = true;
+            mListener.onInitialized();
+        }
+        if (mTaskToken == null) {
+            // Nothing to update, task is not yet available
+            return;
+        }
+        // Reparent the task when this surface is created
+        mTransaction.reparent(mTaskLeash, getSurfaceControl())
+                .show(mTaskLeash)
+                .apply();
+        updateTaskVisibility();
+    }
+
+    @Override
+    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+        if (mTaskToken == null) {
+            return;
+        }
+        onLocationChanged();
+    }
+
+    @Override
+    public void surfaceDestroyed(SurfaceHolder holder) {
+        mSurfaceCreated = false;
+        if (mTaskToken == null) {
+            // Nothing to update, task is not yet available
+            return;
+        }
+
+        // Unparent the task when this surface is destroyed
+        mTransaction.reparent(mTaskLeash, null).apply();
+        updateTaskVisibility();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
index a3b720c..8aca01d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
@@ -56,4 +56,10 @@
      * Interpolator to be used when animating a move based on a click. Pair with enough duration.
      */
     public static final Interpolator TOUCH_RESPONSE = new PathInterpolator(0.3f, 0f, 0.1f, 1f);
+
+    /**
+     * Interpolator to be used when animating a panel closing.
+     */
+    public static final Interpolator PANEL_CLOSE_ACCELERATED =
+            new PathInterpolator(0.3f, 0, 0.5f, 1);
 }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BadgedImageView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BadgedImageView.java
new file mode 100644
index 0000000..4d06c03
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BadgedImageView.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.graphics.Paint.DITHER_FLAG;
+import static android.graphics.Paint.FILTER_BITMAP_FLAG;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.PaintFlagsDrawFilter;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.util.PathParser;
+import android.widget.ImageView;
+
+import com.android.launcher3.icons.DotRenderer;
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.Interpolators;
+
+import java.util.EnumSet;
+
+/**
+ * View that displays an adaptive icon with an app-badge and a dot.
+ *
+ * Dot = a small colored circle that indicates whether this bubble has an unread update.
+ * Badge = the icon associated with the app that created this bubble, this will show work profile
+ * badge if appropriate.
+ */
+public class BadgedImageView extends ImageView {
+
+    /** Same value as Launcher3 dot code */
+    public static final float WHITE_SCRIM_ALPHA = 0.54f;
+    /** Same as value in Launcher3 IconShape */
+    public static final int DEFAULT_PATH_SIZE = 100;
+    /** Same as value in Launcher3 BaseIconFactory */
+    private static final float ICON_BADGE_SCALE = 0.444f;
+
+    /**
+     * Flags that suppress the visibility of the 'new' dot, for one reason or another. If any of
+     * these flags are set, the dot will not be shown even if {@link Bubble#showDot()} returns true.
+     */
+    enum SuppressionFlag {
+        // Suppressed because the flyout is visible - it will morph into the dot via animation.
+        FLYOUT_VISIBLE,
+        // Suppressed because this bubble is behind others in the collapsed stack.
+        BEHIND_STACK,
+    }
+
+    /**
+     * Start by suppressing the dot because the flyout is visible - most bubbles are added with a
+     * flyout, so this is a reasonable default.
+     */
+    private final EnumSet<SuppressionFlag> mDotSuppressionFlags =
+            EnumSet.of(SuppressionFlag.FLYOUT_VISIBLE);
+
+    private float mDotScale = 0f;
+    private float mAnimatingToDotScale = 0f;
+    private boolean mDotIsAnimating = false;
+
+    private BubbleViewProvider mBubble;
+
+    private int mBubbleBitmapSize;
+    private int mBubbleSize;
+    private DotRenderer mDotRenderer;
+    private DotRenderer.DrawParams mDrawParams;
+    private boolean mOnLeft;
+
+    private int mDotColor;
+
+    private Rect mTempBounds = new Rect();
+
+    public BadgedImageView(Context context) {
+        this(context, null);
+    }
+
+    public BadgedImageView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public BadgedImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+        this(context, attrs, defStyleAttr, 0);
+    }
+
+    public BadgedImageView(Context context, AttributeSet attrs, int defStyleAttr,
+            int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+        mBubbleBitmapSize = getResources().getDimensionPixelSize(R.dimen.bubble_bitmap_size);
+        mBubbleSize = getResources().getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mDrawParams = new DotRenderer.DrawParams();
+
+        Path iconPath = PathParser.createPathFromPathData(
+                getResources().getString(com.android.internal.R.string.config_icon_mask));
+        mDotRenderer = new DotRenderer(mBubbleBitmapSize, iconPath, DEFAULT_PATH_SIZE);
+
+        setFocusable(true);
+        setClickable(true);
+    }
+
+    public void showDotAndBadge(boolean onLeft) {
+        removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.BEHIND_STACK);
+        animateDotBadgePositions(onLeft);
+
+    }
+
+    public void hideDotAndBadge(boolean onLeft) {
+        addDotSuppressionFlag(BadgedImageView.SuppressionFlag.BEHIND_STACK);
+        mOnLeft = onLeft;
+        hideBadge();
+    }
+
+    /**
+     * Updates the view with provided info.
+     */
+    public void setRenderedBubble(BubbleViewProvider bubble) {
+        mBubble = bubble;
+        showBadge();
+        mDotColor = bubble.getDotColor();
+        drawDot(bubble.getDotPath());
+    }
+
+    @Override
+    public void onDraw(Canvas canvas) {
+        super.onDraw(canvas);
+
+        if (!shouldDrawDot()) {
+            return;
+        }
+
+        getDrawingRect(mTempBounds);
+
+        mDrawParams.color = mDotColor;
+        mDrawParams.iconBounds = mTempBounds;
+        mDrawParams.leftAlign = mOnLeft;
+        mDrawParams.scale = mDotScale;
+
+        mDotRenderer.draw(canvas, mDrawParams);
+    }
+
+    /** Adds a dot suppression flag, updating dot visibility if needed. */
+    void addDotSuppressionFlag(SuppressionFlag flag) {
+        if (mDotSuppressionFlags.add(flag)) {
+            // Update dot visibility, and animate out if we're now behind the stack.
+            updateDotVisibility(flag == SuppressionFlag.BEHIND_STACK /* animate */);
+        }
+    }
+
+    /** Removes a dot suppression flag, updating dot visibility if needed. */
+    void removeDotSuppressionFlag(SuppressionFlag flag) {
+        if (mDotSuppressionFlags.remove(flag)) {
+            // Update dot visibility, animating if we're no longer behind the stack.
+            updateDotVisibility(flag == SuppressionFlag.BEHIND_STACK);
+        }
+    }
+
+    /** Updates the visibility of the dot, animating if requested. */
+    void updateDotVisibility(boolean animate) {
+        final float targetScale = shouldDrawDot() ? 1f : 0f;
+
+        if (animate) {
+            animateDotScale(targetScale, null /* after */);
+        } else {
+            mDotScale = targetScale;
+            mAnimatingToDotScale = targetScale;
+            invalidate();
+        }
+    }
+
+    /**
+     * @param iconPath The new icon path to use when calculating dot position.
+     */
+    void drawDot(Path iconPath) {
+        mDotRenderer = new DotRenderer(mBubbleBitmapSize, iconPath, DEFAULT_PATH_SIZE);
+        invalidate();
+    }
+
+    /**
+     * How big the dot should be, fraction from 0 to 1.
+     */
+    void setDotScale(float fraction) {
+        mDotScale = fraction;
+        invalidate();
+    }
+
+    /**
+     * Whether decorations (badges or dots) are on the left.
+     */
+    boolean getDotOnLeft() {
+        return mOnLeft;
+    }
+
+    /**
+     * Return dot position relative to bubble view container bounds.
+     */
+    float[] getDotCenter() {
+        float[] dotPosition;
+        if (mOnLeft) {
+            dotPosition = mDotRenderer.getLeftDotPosition();
+        } else {
+            dotPosition = mDotRenderer.getRightDotPosition();
+        }
+        getDrawingRect(mTempBounds);
+        float dotCenterX = mTempBounds.width() * dotPosition[0];
+        float dotCenterY = mTempBounds.height() * dotPosition[1];
+        return new float[]{dotCenterX, dotCenterY};
+    }
+
+    /**
+     * The key for the {@link Bubble} associated with this view, if one exists.
+     */
+    @Nullable
+    public String getKey() {
+        return (mBubble != null) ? mBubble.getKey() : null;
+    }
+
+    int getDotColor() {
+        return mDotColor;
+    }
+
+    /** Sets the position of the dot and badge, animating them out and back in if requested. */
+    void animateDotBadgePositions(boolean onLeft) {
+        mOnLeft = onLeft;
+
+        if (onLeft != getDotOnLeft() && shouldDrawDot()) {
+            animateDotScale(0f /* showDot */, () -> {
+                invalidate();
+                animateDotScale(1.0f, null /* after */);
+            });
+        }
+        // TODO animate badge
+        showBadge();
+
+    }
+
+    /** Sets the position of the dot and badge. */
+    void setDotBadgeOnLeft(boolean onLeft) {
+        mOnLeft = onLeft;
+        invalidate();
+        showBadge();
+    }
+
+
+    /** Whether to draw the dot in onDraw(). */
+    private boolean shouldDrawDot() {
+        // Always render the dot if it's animating, since it could be animating out. Otherwise, show
+        // it if the bubble wants to show it, and we aren't suppressing it.
+        return mDotIsAnimating || (mBubble.showDot() && mDotSuppressionFlags.isEmpty());
+    }
+
+    /**
+     * Animates the dot to the given scale, running the optional callback when the animation ends.
+     */
+    private void animateDotScale(float toScale, @Nullable Runnable after) {
+        mDotIsAnimating = true;
+
+        // Don't restart the animation if we're already animating to the given value.
+        if (mAnimatingToDotScale == toScale || !shouldDrawDot()) {
+            mDotIsAnimating = false;
+            return;
+        }
+
+        mAnimatingToDotScale = toScale;
+
+        final boolean showDot = toScale > 0f;
+
+        // Do NOT wait until after animation ends to setShowDot
+        // to avoid overriding more recent showDot states.
+        clearAnimation();
+        animate()
+                .setDuration(200)
+                .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
+                .setUpdateListener((valueAnimator) -> {
+                    float fraction = valueAnimator.getAnimatedFraction();
+                    fraction = showDot ? fraction : 1f - fraction;
+                    setDotScale(fraction);
+                }).withEndAction(() -> {
+                    setDotScale(showDot ? 1f : 0f);
+                    mDotIsAnimating = false;
+                    if (after != null) {
+                        after.run();
+                    }
+                }).start();
+    }
+
+    void showBadge() {
+        Drawable badge = mBubble.getAppBadge();
+        if (badge == null) {
+            setImageBitmap(mBubble.getBubbleIcon());
+            return;
+        }
+        Canvas bubbleCanvas = new Canvas();
+        Bitmap noBadgeBubble = mBubble.getBubbleIcon();
+        Bitmap bubble = noBadgeBubble.copy(noBadgeBubble.getConfig(), /* isMutable */ true);
+
+        bubbleCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG));
+        bubbleCanvas.setBitmap(bubble);
+
+        final int badgeSize = (int) (ICON_BADGE_SCALE * mBubbleSize);
+        if (mOnLeft) {
+            badge.setBounds(0, mBubbleSize - badgeSize, badgeSize, mBubbleSize);
+        } else {
+            badge.setBounds(mBubbleSize - badgeSize, mBubbleSize - badgeSize,
+                    mBubbleSize, mBubbleSize);
+        }
+        badge.draw(bubbleCanvas);
+        bubbleCanvas.setBitmap(null);
+        setImageBitmap(bubble);
+    }
+
+    void hideBadge() {
+        setImageBitmap(mBubble.getBubbleIcon());
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
new file mode 100644
index 0000000..93ed395
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
@@ -0,0 +1,818 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.os.AsyncTask.Status.FINISHED;
+
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
+import android.annotation.DimenRes;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Person;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ShortcutInfo;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Path;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.os.Parcelable;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.logging.InstanceId;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Encapsulates the data and UI elements of a bubble.
+ */
+@VisibleForTesting
+public class Bubble implements BubbleViewProvider {
+    private static final String TAG = "Bubble";
+
+    private final String mKey;
+
+    private long mLastUpdated;
+    private long mLastAccessed;
+
+    @Nullable
+    private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
+
+    /** Whether the bubble should show a dot for the notification indicating updated content. */
+    private boolean mShowBubbleUpdateDot = true;
+
+    /** Whether flyout text should be suppressed, regardless of any other flags or state. */
+    private boolean mSuppressFlyout;
+
+    // Items that are typically loaded later
+    private String mAppName;
+    private ShortcutInfo mShortcutInfo;
+    private String mMetadataShortcutId;
+    private BadgedImageView mIconView;
+    private BubbleExpandedView mExpandedView;
+
+    private BubbleViewInfoTask mInflationTask;
+    private boolean mInflateSynchronously;
+    private boolean mPendingIntentCanceled;
+    private boolean mIsImportantConversation;
+
+    /**
+     * Presentational info about the flyout.
+     */
+    public static class FlyoutMessage {
+        @Nullable public Icon senderIcon;
+        @Nullable public Drawable senderAvatar;
+        @Nullable public CharSequence senderName;
+        @Nullable public CharSequence message;
+        @Nullable public boolean isGroupChat;
+    }
+
+    private FlyoutMessage mFlyoutMessage;
+    private Drawable mBadgeDrawable;
+    // Bitmap with no badge, no dot
+    private Bitmap mBubbleBitmap;
+    private int mDotColor;
+    private Path mDotPath;
+    private int mFlags;
+
+    @NonNull
+    private UserHandle mUser;
+    @NonNull
+    private String mPackageName;
+    @Nullable
+    private String mTitle;
+    @Nullable
+    private Icon mIcon;
+    private boolean mIsBubble;
+    private boolean mIsVisuallyInterruptive;
+    private boolean mIsClearable;
+    private boolean mShouldSuppressNotificationDot;
+    private boolean mShouldSuppressNotificationList;
+    private boolean mShouldSuppressPeek;
+    private int mDesiredHeight;
+    @DimenRes
+    private int mDesiredHeightResId;
+
+    /** for logging **/
+    @Nullable
+    private InstanceId mInstanceId;
+    @Nullable
+    private String mChannelId;
+    private int mNotificationId;
+    private int mAppUid = -1;
+
+    /**
+     * A bubble is created and can be updated. This intent is updated until the user first
+     * expands the bubble. Once the user has expanded the contents, we ignore the intent updates
+     * to prevent restarting the intent & possibly altering UI state in the activity in front of
+     * the user.
+     *
+     * Once the bubble is overflowed, the activity is finished and updates to the
+     * notification are respected. Typically an update to an overflowed bubble would result in
+     * that bubble being added back to the stack anyways.
+     */
+    @Nullable
+    private PendingIntent mIntent;
+    private boolean mIntentActive;
+    @Nullable
+    private PendingIntent.CancelListener mIntentCancelListener;
+
+    /**
+     * Sent when the bubble & notification are no longer visible to the user (i.e. no
+     * notification in the shade, no bubble in the stack or overflow).
+     */
+    @Nullable
+    private PendingIntent mDeleteIntent;
+
+    /**
+     * Create a bubble with limited information based on given {@link ShortcutInfo}.
+     * Note: Currently this is only being used when the bubble is persisted to disk.
+     */
+    Bubble(@NonNull final String key, @NonNull final ShortcutInfo shortcutInfo,
+            final int desiredHeight, final int desiredHeightResId, @Nullable final String title) {
+        Objects.requireNonNull(key);
+        Objects.requireNonNull(shortcutInfo);
+        mMetadataShortcutId = shortcutInfo.getId();
+        mShortcutInfo = shortcutInfo;
+        mKey = key;
+        mFlags = 0;
+        mUser = shortcutInfo.getUserHandle();
+        mPackageName = shortcutInfo.getPackage();
+        mIcon = shortcutInfo.getIcon();
+        mDesiredHeight = desiredHeight;
+        mDesiredHeightResId = desiredHeightResId;
+        mTitle = title;
+        mShowBubbleUpdateDot = false;
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    Bubble(@NonNull final BubbleEntry entry,
+            @Nullable final Bubbles.NotificationSuppressionChangedListener listener,
+            final Bubbles.PendingIntentCanceledListener intentCancelListener) {
+        mKey = entry.getKey();
+        mSuppressionListener = listener;
+        mIntentCancelListener = intent -> {
+            if (mIntent != null) {
+                mIntent.unregisterCancelListener(mIntentCancelListener);
+            }
+            intentCancelListener.onPendingIntentCanceled(this);
+        };
+        setEntry(entry);
+    }
+
+    @Override
+    public String getKey() {
+        return mKey;
+    }
+
+    public UserHandle getUser() {
+        return mUser;
+    }
+
+    @NonNull
+    public String getPackageName() {
+        return mPackageName;
+    }
+
+    @Override
+    public Bitmap getBubbleIcon() {
+        return mBubbleBitmap;
+    }
+
+    @Override
+    public Drawable getAppBadge() {
+        return mBadgeDrawable;
+    }
+
+    @Override
+    public int getDotColor() {
+        return mDotColor;
+    }
+
+    @Override
+    public Path getDotPath() {
+        return mDotPath;
+    }
+
+    @Nullable
+    public String getAppName() {
+        return mAppName;
+    }
+
+    @Nullable
+    public ShortcutInfo getShortcutInfo() {
+        return mShortcutInfo;
+    }
+
+    @Nullable
+    @Override
+    public BadgedImageView getIconView() {
+        return mIconView;
+    }
+
+    @Override
+    @Nullable
+    public BubbleExpandedView getExpandedView() {
+        return mExpandedView;
+    }
+
+    @Nullable
+    public String getTitle() {
+        return mTitle;
+    }
+
+    String getMetadataShortcutId() {
+        return mMetadataShortcutId;
+    }
+
+    boolean hasMetadataShortcutId() {
+        return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty());
+    }
+
+    /**
+     * Call this to clean up the task for the bubble. Ensure this is always called when done with
+     * the bubble.
+     */
+    void cleanupExpandedView() {
+        if (mExpandedView != null) {
+            mExpandedView.cleanUpExpandedState();
+            mExpandedView = null;
+        }
+        if (mIntent != null) {
+            mIntent.unregisterCancelListener(mIntentCancelListener);
+        }
+        mIntentActive = false;
+    }
+
+    /**
+     * Call when all the views should be removed/cleaned up.
+     */
+    void cleanupViews() {
+        cleanupExpandedView();
+        mIconView = null;
+    }
+
+    void setPendingIntentCanceled() {
+        mPendingIntentCanceled = true;
+    }
+
+    boolean getPendingIntentCanceled() {
+        return mPendingIntentCanceled;
+    }
+
+    /**
+     * Sets whether to perform inflation on the same thread as the caller. This method should only
+     * be used in tests, not in production.
+     */
+    @VisibleForTesting
+    void setInflateSynchronously(boolean inflateSynchronously) {
+        mInflateSynchronously = inflateSynchronously;
+    }
+
+    /**
+     * Sets whether this bubble is considered visually interruptive. This method is purely for
+     * testing.
+     */
+    @VisibleForTesting
+    void setVisuallyInterruptiveForTest(boolean visuallyInterruptive) {
+        mIsVisuallyInterruptive = visuallyInterruptive;
+    }
+
+    /**
+     * Starts a task to inflate & load any necessary information to display a bubble.
+     *
+     * @param callback the callback to notify one the bubble is ready to be displayed.
+     * @param context the context for the bubble.
+     * @param controller
+     * @param stackView the stackView the bubble is eventually added to.
+     * @param iconFactory the iconfactory use to create badged images for the bubble.
+     */
+    void inflate(BubbleViewInfoTask.Callback callback,
+            Context context,
+            BubbleController controller,
+            BubbleStackView stackView,
+            BubbleIconFactory iconFactory,
+            boolean skipInflation) {
+        if (isBubbleLoading()) {
+            mInflationTask.cancel(true /* mayInterruptIfRunning */);
+        }
+        mInflationTask = new BubbleViewInfoTask(this,
+                context,
+                controller,
+                stackView,
+                iconFactory,
+                skipInflation,
+                callback);
+        if (mInflateSynchronously) {
+            mInflationTask.onPostExecute(mInflationTask.doInBackground());
+        } else {
+            mInflationTask.execute();
+        }
+    }
+
+    private boolean isBubbleLoading() {
+        return mInflationTask != null && mInflationTask.getStatus() != FINISHED;
+    }
+
+    boolean isInflated() {
+        return mIconView != null && mExpandedView != null;
+    }
+
+    void stopInflation() {
+        if (mInflationTask == null) {
+            return;
+        }
+        mInflationTask.cancel(true /* mayInterruptIfRunning */);
+    }
+
+    void setViewInfo(BubbleViewInfoTask.BubbleViewInfo info) {
+        if (!isInflated()) {
+            mIconView = info.imageView;
+            mExpandedView = info.expandedView;
+        }
+
+        mShortcutInfo = info.shortcutInfo;
+        mAppName = info.appName;
+        mFlyoutMessage = info.flyoutMessage;
+
+        mBadgeDrawable = info.badgeDrawable;
+        mBubbleBitmap = info.bubbleBitmap;
+
+        mDotColor = info.dotColor;
+        mDotPath = info.dotPath;
+
+        if (mExpandedView != null) {
+            mExpandedView.update(this /* bubble */);
+        }
+        if (mIconView != null) {
+            mIconView.setRenderedBubble(this /* bubble */);
+        }
+    }
+
+    /**
+     * Set visibility of bubble in the expanded state.
+     *
+     * @param visibility {@code true} if the expanded bubble should be visible on the screen.
+     *
+     * Note that this contents visibility doesn't affect visibility at {@link android.view.View},
+     * and setting {@code false} actually means rendering the expanded view in transparent.
+     */
+    @Override
+    public void setContentVisibility(boolean visibility) {
+        if (mExpandedView != null) {
+            mExpandedView.setContentVisibility(visibility);
+        }
+    }
+
+    /**
+     * Sets the entry associated with this bubble.
+     */
+    void setEntry(@NonNull final BubbleEntry entry) {
+        Objects.requireNonNull(entry);
+        mLastUpdated = entry.getStatusBarNotification().getPostTime();
+        mIsBubble = entry.getStatusBarNotification().getNotification().isBubbleNotification();
+        mPackageName = entry.getStatusBarNotification().getPackageName();
+        mUser = entry.getStatusBarNotification().getUser();
+        mTitle = getTitle(entry);
+        mChannelId = entry.getStatusBarNotification().getNotification().getChannelId();
+        mNotificationId = entry.getStatusBarNotification().getId();
+        mAppUid = entry.getStatusBarNotification().getUid();
+        mInstanceId = entry.getStatusBarNotification().getInstanceId();
+        mFlyoutMessage = extractFlyoutMessage(entry);
+        if (entry.getRanking() != null) {
+            mShortcutInfo = entry.getRanking().getConversationShortcutInfo();
+            mIsVisuallyInterruptive = entry.getRanking().visuallyInterruptive();
+            if (entry.getRanking().getChannel() != null) {
+                mIsImportantConversation =
+                        entry.getRanking().getChannel().isImportantConversation();
+            }
+        }
+        if (entry.getBubbleMetadata() != null) {
+            mMetadataShortcutId = entry.getBubbleMetadata().getShortcutId();
+            mFlags = entry.getBubbleMetadata().getFlags();
+            mDesiredHeight = entry.getBubbleMetadata().getDesiredHeight();
+            mDesiredHeightResId = entry.getBubbleMetadata().getDesiredHeightResId();
+            mIcon = entry.getBubbleMetadata().getIcon();
+
+            if (!mIntentActive || mIntent == null) {
+                if (mIntent != null) {
+                    mIntent.unregisterCancelListener(mIntentCancelListener);
+                }
+                mIntent = entry.getBubbleMetadata().getIntent();
+                if (mIntent != null) {
+                    mIntent.registerCancelListener(mIntentCancelListener);
+                }
+            } else if (mIntent != null && entry.getBubbleMetadata().getIntent() == null) {
+                // Was an intent bubble now it's a shortcut bubble... still unregister the listener
+                mIntent.unregisterCancelListener(mIntentCancelListener);
+                mIntentActive = false;
+                mIntent = null;
+            }
+            mDeleteIntent = entry.getBubbleMetadata().getDeleteIntent();
+        }
+
+        mIsClearable = entry.isClearable();
+        mShouldSuppressNotificationDot = entry.shouldSuppressNotificationDot();
+        mShouldSuppressNotificationList = entry.shouldSuppressNotificationList();
+        mShouldSuppressPeek = entry.shouldSuppressPeek();
+    }
+
+    @Nullable
+    Icon getIcon() {
+        return mIcon;
+    }
+
+    boolean isVisuallyInterruptive() {
+        return mIsVisuallyInterruptive;
+    }
+
+    /**
+     * @return the last time this bubble was updated or accessed, whichever is most recent.
+     */
+    long getLastActivity() {
+        return Math.max(mLastUpdated, mLastAccessed);
+    }
+
+    /**
+     * Sets if the intent used for this bubble is currently active (i.e. populating an
+     * expanded view, expanded or not).
+     */
+    void setIntentActive() {
+        mIntentActive = true;
+    }
+
+    boolean isIntentActive() {
+        return mIntentActive;
+    }
+
+    public InstanceId getInstanceId() {
+        return mInstanceId;
+    }
+
+    @Nullable
+    public String getChannelId() {
+        return mChannelId;
+    }
+
+    public int getNotificationId() {
+        return mNotificationId;
+    }
+
+    /**
+     * @return the task id of the task in which bubble contents is drawn.
+     */
+    @Override
+    public int getTaskId() {
+        return mExpandedView != null ? mExpandedView.getTaskId() : INVALID_TASK_ID;
+    }
+
+    /**
+     * Should be invoked whenever a Bubble is accessed (selected while expanded).
+     */
+    void markAsAccessedAt(long lastAccessedMillis) {
+        mLastAccessed = lastAccessedMillis;
+        setSuppressNotification(true);
+        setShowDot(false /* show */);
+    }
+
+    /**
+     * Should be invoked whenever a Bubble is promoted from overflow.
+     */
+    void markUpdatedAt(long lastAccessedMillis) {
+        mLastUpdated = lastAccessedMillis;
+    }
+
+    /**
+     * Whether this notification should be shown in the shade.
+     */
+    boolean showInShade() {
+        return !shouldSuppressNotification() || !mIsClearable;
+    }
+
+    /**
+     * Whether this notification conversation is important.
+     */
+    boolean isImportantConversation() {
+        return mIsImportantConversation;
+    }
+
+    /**
+     * Sets whether this notification should be suppressed in the shade.
+     */
+    @VisibleForTesting
+    public void setSuppressNotification(boolean suppressNotification) {
+        boolean prevShowInShade = showInShade();
+        if (suppressNotification) {
+            mFlags |= Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
+        } else {
+            mFlags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
+        }
+
+        if (showInShade() != prevShowInShade && mSuppressionListener != null) {
+            mSuppressionListener.onBubbleNotificationSuppressionChange(this);
+        }
+    }
+
+    /**
+     * Sets whether the bubble for this notification should show a dot indicating updated content.
+     */
+    void setShowDot(boolean showDot) {
+        mShowBubbleUpdateDot = showDot;
+
+        if (mIconView != null) {
+            mIconView.updateDotVisibility(true /* animate */);
+        }
+    }
+
+    /**
+     * Whether the bubble for this notification should show a dot indicating updated content.
+     */
+    @Override
+    public boolean showDot() {
+        return mShowBubbleUpdateDot
+                && !mShouldSuppressNotificationDot
+                && !shouldSuppressNotification();
+    }
+
+    /**
+     * Whether the flyout for the bubble should be shown.
+     */
+    @VisibleForTesting
+    public boolean showFlyout() {
+        return !mSuppressFlyout && !mShouldSuppressPeek
+                && !shouldSuppressNotification()
+                && !mShouldSuppressNotificationList;
+    }
+
+    /**
+     * Set whether the flyout text for the bubble should be shown when an update is received.
+     *
+     * @param suppressFlyout whether the flyout text is shown
+     */
+    void setSuppressFlyout(boolean suppressFlyout) {
+        mSuppressFlyout = suppressFlyout;
+    }
+
+    FlyoutMessage getFlyoutMessage() {
+        return mFlyoutMessage;
+    }
+
+    int getRawDesiredHeight() {
+        return mDesiredHeight;
+    }
+
+    int getRawDesiredHeightResId() {
+        return mDesiredHeightResId;
+    }
+
+    float getDesiredHeight(Context context) {
+        boolean useRes = mDesiredHeightResId != 0;
+        if (useRes) {
+            return getDimenForPackageUser(context, mDesiredHeightResId, mPackageName,
+                    mUser.getIdentifier());
+        } else {
+            return mDesiredHeight * context.getResources().getDisplayMetrics().density;
+        }
+    }
+
+    String getDesiredHeightString() {
+        boolean useRes = mDesiredHeightResId != 0;
+        if (useRes) {
+            return String.valueOf(mDesiredHeightResId);
+        } else {
+            return String.valueOf(mDesiredHeight);
+        }
+    }
+
+    @Nullable
+    PendingIntent getBubbleIntent() {
+        return mIntent;
+    }
+
+    @Nullable
+    PendingIntent getDeleteIntent() {
+        return mDeleteIntent;
+    }
+
+    Intent getSettingsIntent(final Context context) {
+        final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS);
+        intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
+        final int uid = getUid(context);
+        if (uid != -1) {
+            intent.putExtra(Settings.EXTRA_APP_UID, uid);
+        }
+        intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
+        return intent;
+    }
+
+    public int getAppUid() {
+        return mAppUid;
+    }
+
+    private int getUid(final Context context) {
+        if (mAppUid != -1) return mAppUid;
+        final PackageManager pm = context.getPackageManager();
+        if (pm == null) return -1;
+        try {
+            final ApplicationInfo info = pm.getApplicationInfo(mShortcutInfo.getPackage(), 0);
+            return info.uid;
+        } catch (PackageManager.NameNotFoundException e) {
+            Log.e(TAG, "cannot find uid", e);
+        }
+        return -1;
+    }
+
+    private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) {
+        Resources r;
+        if (pkg != null) {
+            try {
+                if (userId == UserHandle.USER_ALL) {
+                    userId = UserHandle.USER_SYSTEM;
+                }
+                r = context.createContextAsUser(UserHandle.of(userId), /* flags */ 0)
+                        .getPackageManager().getResourcesForApplication(pkg);
+                return r.getDimensionPixelSize(resId);
+            } catch (PackageManager.NameNotFoundException ex) {
+                // Uninstalled, don't care
+            } catch (Resources.NotFoundException e) {
+                // Invalid res id, return 0 and user our default
+                Log.e(TAG, "Couldn't find desired height res id", e);
+            }
+        }
+        return 0;
+    }
+
+    private boolean shouldSuppressNotification() {
+        return isEnabled(Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION);
+    }
+
+    public boolean shouldAutoExpand() {
+        return isEnabled(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+    }
+
+    void setShouldAutoExpand(boolean shouldAutoExpand) {
+        if (shouldAutoExpand) {
+            enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+        } else {
+            disable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE);
+        }
+    }
+
+    public void setIsBubble(final boolean isBubble) {
+        mIsBubble = isBubble;
+    }
+
+    public boolean isBubble() {
+        return mIsBubble;
+    }
+
+    public void enable(int option) {
+        mFlags |= option;
+    }
+
+    public void disable(int option) {
+        mFlags &= ~option;
+    }
+
+    public boolean isEnabled(int option) {
+        return (mFlags & option) != 0;
+    }
+
+    @Override
+    public String toString() {
+        return "Bubble{" + mKey + '}';
+    }
+
+    /**
+     * Description of current bubble state.
+     */
+    public void dump(
+            @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
+        pw.print("key: "); pw.println(mKey);
+        pw.print("  showInShade:   "); pw.println(showInShade());
+        pw.print("  showDot:       "); pw.println(showDot());
+        pw.print("  showFlyout:    "); pw.println(showFlyout());
+        pw.print("  lastActivity:  "); pw.println(getLastActivity());
+        pw.print("  desiredHeight: "); pw.println(getDesiredHeightString());
+        pw.print("  suppressNotif: "); pw.println(shouldSuppressNotification());
+        pw.print("  autoExpand:    "); pw.println(shouldAutoExpand());
+        if (mExpandedView != null) {
+            mExpandedView.dump(fd, pw, args);
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof Bubble)) return false;
+        Bubble bubble = (Bubble) o;
+        return Objects.equals(mKey, bubble.mKey);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mKey);
+    }
+
+    @Nullable
+    private static String getTitle(@NonNull final BubbleEntry e) {
+        final CharSequence titleCharSeq = e.getStatusBarNotification()
+                .getNotification().extras.getCharSequence(Notification.EXTRA_TITLE);
+        return titleCharSeq == null ? null : titleCharSeq.toString();
+    }
+
+    /**
+     * Returns our best guess for the most relevant text summary of the latest update to this
+     * notification, based on its type. Returns null if there should not be an update message.
+     */
+    @NonNull
+    static Bubble.FlyoutMessage extractFlyoutMessage(BubbleEntry entry) {
+        Objects.requireNonNull(entry);
+        final Notification underlyingNotif = entry.getStatusBarNotification().getNotification();
+        final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle();
+
+        Bubble.FlyoutMessage bubbleMessage = new Bubble.FlyoutMessage();
+        bubbleMessage.isGroupChat = underlyingNotif.extras.getBoolean(
+                Notification.EXTRA_IS_GROUP_CONVERSATION);
+        try {
+            if (Notification.BigTextStyle.class.equals(style)) {
+                // Return the big text, it is big so probably important. If it's not there use the
+                // normal text.
+                CharSequence bigText =
+                        underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
+                bubbleMessage.message = !TextUtils.isEmpty(bigText)
+                        ? bigText
+                        : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
+                return bubbleMessage;
+            } else if (Notification.MessagingStyle.class.equals(style)) {
+                final List<Notification.MessagingStyle.Message> messages =
+                        Notification.MessagingStyle.Message.getMessagesFromBundleArray(
+                                (Parcelable[]) underlyingNotif.extras.get(
+                                        Notification.EXTRA_MESSAGES));
+
+                final Notification.MessagingStyle.Message latestMessage =
+                        Notification.MessagingStyle.findLatestIncomingMessage(messages);
+                if (latestMessage != null) {
+                    bubbleMessage.message = latestMessage.getText();
+                    Person sender = latestMessage.getSenderPerson();
+                    bubbleMessage.senderName = sender != null ? sender.getName() : null;
+                    bubbleMessage.senderAvatar = null;
+                    bubbleMessage.senderIcon = sender != null ? sender.getIcon() : null;
+                    return bubbleMessage;
+                }
+            } else if (Notification.InboxStyle.class.equals(style)) {
+                CharSequence[] lines =
+                        underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
+
+                // Return the last line since it should be the most recent.
+                if (lines != null && lines.length > 0) {
+                    bubbleMessage.message = lines[lines.length - 1];
+                    return bubbleMessage;
+                }
+            } else if (Notification.MediaStyle.class.equals(style)) {
+                // Return nothing, media updates aren't typically useful as a text update.
+                return bubbleMessage;
+            } else {
+                // Default to text extra.
+                bubbleMessage.message =
+                        underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
+                return bubbleMessage;
+            }
+        } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) {
+            // No use crashing, we'll just return null and the caller will assume there's no update
+            // message.
+            e.printStackTrace();
+        }
+
+        return bubbleMessage;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
new file mode 100644
index 0000000..05acb55
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
@@ -0,0 +1,1152 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.service.notification.NotificationListenerService.REASON_CANCEL;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
+import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.annotation.NonNull;
+import android.annotation.UserIdInt;
+import android.app.ActivityManager;
+import android.app.ActivityTaskManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.pm.LauncherApps;
+import android.content.pm.PackageManager;
+import android.content.pm.ShortcutInfo;
+import android.content.res.Configuration;
+import android.graphics.PixelFormat;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.os.UserHandle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.NotificationListenerService.RankingMap;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseSetArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+
+import androidx.annotation.MainThread;
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.logging.UiEventLogger;
+import com.android.internal.statusbar.IStatusBarService;
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.WindowManagerShellWrapper;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.pip.PinnedStackListenerForwarder;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.IntConsumer;
+
+/**
+ * Bubbles are a special type of content that can "float" on top of other apps or System UI.
+ * Bubbles can be expanded to show more content.
+ *
+ * The controller manages addition, removal, and visible state of bubbles on screen.
+ */
+public class BubbleController implements Bubbles {
+
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleController" : TAG_BUBBLES;
+
+    private final Context mContext;
+    private BubbleExpandListener mExpandListener;
+    @Nullable private BubbleStackView.SurfaceSynchronizer mSurfaceSynchronizer;
+    private final FloatingContentCoordinator mFloatingContentCoordinator;
+    private final BubbleDataRepository mDataRepository;
+    private BubbleLogger mLogger;
+    private final Handler mMainHandler;
+    private BubbleData mBubbleData;
+    private View mBubbleScrim;
+    @Nullable private BubbleStackView mStackView;
+    private BubbleIconFactory mBubbleIconFactory;
+    private BubblePositioner mBubblePositioner;
+    private SysuiProxy mSysuiProxy;
+
+    /**
+     * The relative position of the stack when we removed it and nulled it out. If the stack is
+     * re-created, it will re-appear at this position.
+     */
+    @Nullable private BubbleStackView.RelativeStackPosition mPositionFromRemovedStack;
+
+    // Tracks the id of the current (foreground) user.
+    private int mCurrentUserId;
+    // Saves notification keys of active bubbles when users are switched.
+    private final SparseSetArray<String> mSavedBubbleKeysPerUser;
+
+    // Used when ranking updates occur and we check if things should bubble / unbubble
+    private NotificationListenerService.Ranking mTmpRanking;
+
+    // Callback that updates BubbleOverflowActivity on data change.
+    @Nullable private BubbleData.Listener mOverflowListener = null;
+
+    // Only load overflow data from disk once
+    private boolean mOverflowDataLoaded = false;
+
+    /**
+     * When the shade status changes to SHADE (from anything but SHADE, like LOCKED) we'll select
+     * this bubble and expand the stack.
+     */
+    @Nullable private BubbleEntry mNotifEntryToExpandOnShadeUnlock;
+
+    private IStatusBarService mBarService;
+    private WindowManager mWindowManager;
+
+    // Used to post to main UI thread
+    private Handler mHandler = new Handler();
+
+    /** LayoutParams used to add the BubbleStackView to the window manager. */
+    private WindowManager.LayoutParams mWmLayoutParams;
+    /** Whether or not the BubbleStackView has been added to the WindowManager. */
+    private boolean mAddedToWindowManager = false;
+
+    /** Last known orientation, used to detect orientation changes in {@link #onConfigChanged}. */
+    private int mOrientation = Configuration.ORIENTATION_UNDEFINED;
+
+    /**
+     * Last known screen density, used to detect display size changes in {@link #onConfigChanged}.
+     */
+    private int mDensityDpi = Configuration.DENSITY_DPI_UNDEFINED;
+
+    /**
+     * Last known font scale, used to detect font size changes in {@link #onConfigChanged}.
+     */
+    private float mFontScale = 0;
+
+    /** Last known direction, used to detect layout direction changes @link #onConfigChanged}. */
+    private int mLayoutDirection = View.LAYOUT_DIRECTION_UNDEFINED;
+
+    private boolean mInflateSynchronously;
+
+    private ShellTaskOrganizer mTaskOrganizer;
+
+    /**
+     * Whether the IME is visible, as reported by the BubbleStackView. If it is, we'll make the
+     * Bubbles window NOT_FOCUSABLE so that touches on the Bubbles UI doesn't steal focus from the
+     * ActivityView and hide the IME.
+     */
+    private boolean mImeVisible = false;
+
+    /** true when user is in status bar unlock shade. */
+    private boolean mIsStatusBarShade = true;
+
+    /**
+     * Injected constructor.
+     */
+    public static BubbleController create(Context context,
+            @Nullable BubbleStackView.SurfaceSynchronizer synchronizer,
+            FloatingContentCoordinator floatingContentCoordinator,
+            @Nullable IStatusBarService statusBarService,
+            WindowManager windowManager,
+            WindowManagerShellWrapper windowManagerShellWrapper,
+            LauncherApps launcherApps,
+            UiEventLogger uiEventLogger,
+            Handler mainHandler,
+            ShellTaskOrganizer organizer) {
+        BubbleLogger logger = new BubbleLogger(uiEventLogger);
+        return new BubbleController(context,
+                new BubbleData(context, logger), synchronizer,
+                floatingContentCoordinator, new BubbleDataRepository(context, launcherApps),
+                statusBarService, windowManager,
+                windowManagerShellWrapper, launcherApps, logger, mainHandler, organizer,
+                new BubblePositioner(context, windowManager));
+    }
+
+    /**
+     * Testing constructor.
+     */
+    @VisibleForTesting
+    public BubbleController(Context context,
+            BubbleData data,
+            @Nullable BubbleStackView.SurfaceSynchronizer synchronizer,
+            FloatingContentCoordinator floatingContentCoordinator,
+            BubbleDataRepository dataRepository,
+            @Nullable IStatusBarService statusBarService,
+            WindowManager windowManager,
+            WindowManagerShellWrapper windowManagerShellWrapper,
+            LauncherApps launcherApps,
+            BubbleLogger bubbleLogger,
+            Handler mainHandler,
+            ShellTaskOrganizer organizer,
+            BubblePositioner positioner) {
+        mContext = context;
+        mFloatingContentCoordinator = floatingContentCoordinator;
+        mDataRepository = dataRepository;
+        mLogger = bubbleLogger;
+        mMainHandler = mainHandler;
+
+        mBubbleData = data;
+        mBubbleData.setListener(mBubbleDataListener);
+        mBubbleData.setSuppressionChangedListener(bubble -> {
+            // Make sure NoMan knows it's not showing in the shade anymore so anyone querying it
+            // can tell.
+            try {
+                mBarService.onBubbleNotificationSuppressionChanged(bubble.getKey(),
+                        !bubble.showInShade());
+            } catch (RemoteException e) {
+                // Bad things have happened
+            }
+        });
+        mBubbleData.setPendingIntentCancelledListener(bubble -> {
+            if (bubble.getBubbleIntent() == null) {
+                return;
+            }
+            if (bubble.isIntentActive() || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+                bubble.setPendingIntentCanceled();
+                return;
+            }
+            mHandler.post(() -> removeBubble(bubble.getKey(), DISMISS_INVALID_INTENT));
+        });
+
+        try {
+            windowManagerShellWrapper.addPinnedStackListener(new BubblesImeListener());
+        } catch (RemoteException e) {
+            e.printStackTrace();
+        }
+        mSurfaceSynchronizer = synchronizer;
+
+        mWindowManager = windowManager;
+        mBarService = statusBarService == null
+                ? IStatusBarService.Stub.asInterface(
+                        ServiceManager.getService(Context.STATUS_BAR_SERVICE))
+                : statusBarService;
+
+        mSavedBubbleKeysPerUser = new SparseSetArray<>();
+        mCurrentUserId = ActivityManager.getCurrentUser();
+        mBubbleData.setCurrentUserId(mCurrentUserId);
+
+        mBubbleIconFactory = new BubbleIconFactory(context);
+        mTaskOrganizer = organizer;
+        mBubblePositioner = positioner;
+
+        launcherApps.registerCallback(new LauncherApps.Callback() {
+            @Override
+            public void onPackageAdded(String s, UserHandle userHandle) {}
+
+            @Override
+            public void onPackageChanged(String s, UserHandle userHandle) {}
+
+            @Override
+            public void onPackageRemoved(String s, UserHandle userHandle) {
+                // Remove bubbles with this package name, since it has been uninstalled and attempts
+                // to open a bubble from an uninstalled app can cause issues.
+                mBubbleData.removeBubblesWithPackageName(s, DISMISS_PACKAGE_REMOVED);
+            }
+
+            @Override
+            public void onPackagesAvailable(String[] strings, UserHandle userHandle, boolean b) {}
+
+            @Override
+            public void onPackagesUnavailable(String[] packages, UserHandle userHandle,
+                    boolean b) {
+                for (String packageName : packages) {
+                    // Remove bubbles from unavailable apps. This can occur when the app is on
+                    // external storage that has been removed.
+                    mBubbleData.removeBubblesWithPackageName(packageName, DISMISS_PACKAGE_REMOVED);
+                }
+            }
+
+            @Override
+            public void onShortcutsChanged(String packageName, List<ShortcutInfo> validShortcuts,
+                    UserHandle user) {
+                super.onShortcutsChanged(packageName, validShortcuts, user);
+
+                // Remove bubbles whose shortcuts aren't in the latest list of valid shortcuts.
+                mBubbleData.removeBubblesWithInvalidShortcuts(
+                        packageName, validShortcuts, DISMISS_SHORTCUT_REMOVED);
+            }
+        });
+    }
+
+    /**
+     * Hides the current input method, wherever it may be focused, via InputMethodManagerInternal.
+     */
+    void hideCurrentInputMethod() {
+        try {
+            mBarService.hideCurrentInputMethodForBubbles();
+        } catch (RemoteException e) {
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * Called when the status bar has become visible or invisible (either permanently or
+     * temporarily).
+     */
+    @Override
+    public void onStatusBarVisibilityChanged(boolean visible) {
+        if (mStackView != null) {
+            // Hide the stack temporarily if the status bar has been made invisible, and the stack
+            // is collapsed. An expanded stack should remain visible until collapsed.
+            mStackView.setTemporarilyInvisible(!visible && !isStackExpanded());
+        }
+    }
+
+    @Override
+    public void onZenStateChanged() {
+        for (Bubble b : mBubbleData.getBubbles()) {
+            b.setShowDot(b.showInShade());
+        }
+    }
+
+    @Override
+    public void onStatusBarStateChanged(boolean isShade) {
+        mIsStatusBarShade = isShade;
+        if (!mIsStatusBarShade) {
+            collapseStack();
+        }
+
+        if (mNotifEntryToExpandOnShadeUnlock != null) {
+            expandStackAndSelectBubble(mNotifEntryToExpandOnShadeUnlock);
+            mNotifEntryToExpandOnShadeUnlock = null;
+        }
+
+        updateStack();
+    }
+
+    @Override
+    public void onUserChanged(int newUserId) {
+        saveBubbles(mCurrentUserId);
+        mBubbleData.dismissAll(DISMISS_USER_CHANGED);
+        restoreBubbles(newUserId);
+        mCurrentUserId = newUserId;
+        mBubbleData.setCurrentUserId(newUserId);
+    }
+
+    /**
+     * Sets whether to perform inflation on the same thread as the caller. This method should only
+     * be used in tests, not in production.
+     */
+    @VisibleForTesting
+    public void setInflateSynchronously(boolean inflateSynchronously) {
+        mInflateSynchronously = inflateSynchronously;
+    }
+
+    /** Set a listener to be notified of when overflow view update. */
+    public void setOverflowListener(BubbleData.Listener listener) {
+        mOverflowListener = listener;
+    }
+
+    /**
+     * @return Bubbles for updating overflow.
+     */
+    List<Bubble> getOverflowBubbles() {
+        return mBubbleData.getOverflowBubbles();
+    }
+
+    /** The task listener for events in bubble tasks. */
+    public ShellTaskOrganizer getTaskOrganizer() {
+        return mTaskOrganizer;
+    }
+
+    /** Contains information to help position things on the screen.  */
+    BubblePositioner getPositioner() {
+        return mBubblePositioner;
+    }
+
+    SysuiProxy getSysuiProxy() {
+        return mSysuiProxy;
+    }
+
+    /**
+     * BubbleStackView is lazily created by this method the first time a Bubble is added. This
+     * method initializes the stack view and adds it to the StatusBar just above the scrim.
+     */
+    private void ensureStackViewCreated() {
+        if (mStackView == null) {
+            mStackView = new BubbleStackView(
+                    mContext, this, mBubbleData, mSurfaceSynchronizer, mFloatingContentCoordinator);
+            mStackView.setStackStartPosition(mPositionFromRemovedStack);
+            mStackView.addView(mBubbleScrim);
+            mStackView.onOrientationChanged();
+            if (mExpandListener != null) {
+                mStackView.setExpandListener(mExpandListener);
+            }
+            mStackView.setUnbubbleConversationCallback(mSysuiProxy::onUnbubbleConversation);
+        }
+
+        addToWindowManagerMaybe();
+    }
+
+    /** Adds the BubbleStackView to the WindowManager if it's not already there. */
+    private void addToWindowManagerMaybe() {
+        // If the stack is null, or already added, don't add it.
+        if (mStackView == null || mAddedToWindowManager) {
+            return;
+        }
+
+        mWmLayoutParams = new WindowManager.LayoutParams(
+                // Fill the screen so we can use translation animations to position the bubble
+                // stack. We'll use touchable regions to ignore touches that are not on the bubbles
+                // themselves.
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+                    | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+                    | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
+                PixelFormat.TRANSLUCENT);
+
+        mWmLayoutParams.setTrustedOverlay();
+        mWmLayoutParams.setFitInsetsTypes(0);
+        mWmLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
+        mWmLayoutParams.token = new Binder();
+        mWmLayoutParams.setTitle("Bubbles!");
+        mWmLayoutParams.packageName = mContext.getPackageName();
+        mWmLayoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
+
+        try {
+            mAddedToWindowManager = true;
+            mWindowManager.addView(mStackView, mWmLayoutParams);
+        } catch (IllegalStateException e) {
+            // This means the stack has already been added. This shouldn't happen...
+            e.printStackTrace();
+        }
+    }
+
+    void onImeVisibilityChanged(boolean imeVisible) {
+        mImeVisible = imeVisible;
+    }
+
+    /** Removes the BubbleStackView from the WindowManager if it's there. */
+    private void removeFromWindowManagerMaybe() {
+        if (!mAddedToWindowManager) {
+            return;
+        }
+
+        try {
+            mAddedToWindowManager = false;
+            if (mStackView != null) {
+                mPositionFromRemovedStack = mStackView.getRelativeStackPosition();
+                mWindowManager.removeView(mStackView);
+                mStackView.removeView(mBubbleScrim);
+                mStackView = null;
+            } else {
+                Log.w(TAG, "StackView added to WindowManager, but was null when removing!");
+            }
+        } catch (IllegalArgumentException e) {
+            // This means the stack has already been removed - it shouldn't happen, but ignore if it
+            // does, since we wanted it removed anyway.
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * Called by the BubbleStackView and whenever all bubbles have animated out, and none have been
+     * added in the meantime.
+     */
+    void onAllBubblesAnimatedOut() {
+        if (mStackView != null) {
+            mStackView.setVisibility(INVISIBLE);
+            removeFromWindowManagerMaybe();
+        }
+    }
+
+    /**
+     * Records the notification key for any active bubbles. These are used to restore active
+     * bubbles when the user returns to the foreground.
+     *
+     * @param userId the id of the user
+     */
+    private void saveBubbles(@UserIdInt int userId) {
+        // First clear any existing keys that might be stored.
+        mSavedBubbleKeysPerUser.remove(userId);
+        // Add in all active bubbles for the current user.
+        for (Bubble bubble: mBubbleData.getBubbles()) {
+            mSavedBubbleKeysPerUser.add(userId, bubble.getKey());
+        }
+    }
+
+    /**
+     * Promotes existing notifications to Bubbles if they were previously bubbles.
+     *
+     * @param userId the id of the user
+     */
+    private void restoreBubbles(@UserIdInt int userId) {
+        ArraySet<String> savedBubbleKeys = mSavedBubbleKeysPerUser.get(userId);
+        if (savedBubbleKeys == null) {
+            // There were no bubbles saved for this used.
+            return;
+        }
+        for (BubbleEntry e : mSysuiProxy.getShouldRestoredEntries(savedBubbleKeys)) {
+            if (canLaunchInActivityView(mContext, e)) {
+                updateBubble(e, true /* suppressFlyout */, false /* showInShade */);
+            }
+        }
+        // Finally, remove the entries for this user now that bubbles are restored.
+        mSavedBubbleKeysPerUser.remove(mCurrentUserId);
+    }
+
+    @Override
+    public void updateForThemeChanges() {
+        if (mStackView != null) {
+            mStackView.onThemeChanged();
+        }
+        mBubbleIconFactory = new BubbleIconFactory(mContext);
+        // Reload each bubble
+        for (Bubble b: mBubbleData.getBubbles()) {
+            b.inflate(null /* callback */, mContext, this, mStackView, mBubbleIconFactory,
+                    false /* skipInflation */);
+        }
+        for (Bubble b: mBubbleData.getOverflowBubbles()) {
+            b.inflate(null /* callback */, mContext, this, mStackView, mBubbleIconFactory,
+                    false /* skipInflation */);
+        }
+    }
+
+    @Override
+    public void onConfigChanged(Configuration newConfig) {
+        if (mBubblePositioner != null) {
+            // This doesn't trigger any changes, always update it
+            mBubblePositioner.update(newConfig.orientation);
+        }
+        if (mStackView != null && newConfig != null) {
+            if (newConfig.orientation != mOrientation) {
+                mOrientation = newConfig.orientation;
+                mStackView.onOrientationChanged();
+            }
+            if (newConfig.densityDpi != mDensityDpi) {
+                mDensityDpi = newConfig.densityDpi;
+                mBubbleIconFactory = new BubbleIconFactory(mContext);
+                mStackView.onDisplaySizeChanged();
+            }
+            if (newConfig.fontScale != mFontScale) {
+                mFontScale = newConfig.fontScale;
+                mStackView.updateFlyout(mFontScale);
+            }
+            if (newConfig.getLayoutDirection() != mLayoutDirection) {
+                mLayoutDirection = newConfig.getLayoutDirection();
+                mStackView.onLayoutDirectionChanged(mLayoutDirection);
+            }
+        }
+    }
+
+    @Override
+    public void setBubbleScrim(View view) {
+        mBubbleScrim = view;
+    }
+
+    @Override
+    public void setSysuiProxy(SysuiProxy proxy) {
+        mSysuiProxy = proxy;
+    }
+
+    @Override
+    public void setExpandListener(BubbleExpandListener listener) {
+        mExpandListener = ((isExpanding, key) -> {
+            if (listener != null) {
+                listener.onBubbleExpandChanged(isExpanding, key);
+            }
+        });
+        if (mStackView != null) {
+            mStackView.setExpandListener(mExpandListener);
+        }
+    }
+
+    /**
+     * Whether or not there are bubbles present, regardless of them being visible on the
+     * screen (e.g. if on AOD).
+     */
+    @VisibleForTesting
+    public boolean hasBubbles() {
+        if (mStackView == null) {
+            return false;
+        }
+        return mBubbleData.hasBubbles();
+    }
+
+    @Override
+    public boolean isStackExpanded() {
+        return mBubbleData.isExpanded();
+    }
+
+    @Override
+    public void collapseStack() {
+        mBubbleData.setExpanded(false /* expanded */);
+    }
+
+    @Override
+    public boolean isBubbleNotificationSuppressedFromShade(String key, String groupKey) {
+        boolean isSuppressedBubble = (mBubbleData.hasAnyBubbleWithKey(key)
+                && !mBubbleData.getAnyBubbleWithkey(key).showInShade());
+
+        boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey);
+        boolean isSummary = key.equals(mBubbleData.getSummaryKey(groupKey));
+        return (isSummary && isSuppressedSummary) || isSuppressedBubble;
+    }
+
+    @Override
+    public boolean isSummarySuppressed(String groupKey) {
+        return mBubbleData.isSummarySuppressed(groupKey);
+    }
+
+    @Override
+    public void removeSuppressedSummary(String groupKey) {
+        mBubbleData.removeSuppressedSummary(groupKey);
+    }
+
+    @Override
+    public String getSummaryKey(String groupKey) {
+        return mBubbleData.getSummaryKey(groupKey);
+    }
+
+    @Override
+    public boolean isBubbleExpanded(String key) {
+        return isStackExpanded() && mBubbleData != null && mBubbleData.getSelectedBubble() != null
+                && mBubbleData.getSelectedBubble().getKey().equals(key);
+    }
+
+    /** Promote the provided bubble from the overflow view. */
+    public void promoteBubbleFromOverflow(Bubble bubble) {
+        mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_BACK_TO_STACK);
+        bubble.setInflateSynchronously(mInflateSynchronously);
+        bubble.setShouldAutoExpand(true);
+        bubble.markAsAccessedAt(System.currentTimeMillis());
+        setIsBubble(bubble, true /* isBubble */);
+    }
+
+    @Override
+    public void expandStackAndSelectBubble(BubbleEntry entry) {
+        if (mIsStatusBarShade) {
+            mNotifEntryToExpandOnShadeUnlock = null;
+
+            String key = entry.getKey();
+            Bubble bubble = mBubbleData.getBubbleInStackWithKey(key);
+            if (bubble != null) {
+                mBubbleData.setSelectedBubble(bubble);
+                mBubbleData.setExpanded(true);
+            } else {
+                bubble = mBubbleData.getOverflowBubbleWithKey(key);
+                if (bubble != null) {
+                    promoteBubbleFromOverflow(bubble);
+                } else if (entry.canBubble()) {
+                    // It can bubble but it's not -- it got aged out of the overflow before it
+                    // was dismissed or opened, make it a bubble again.
+                    setIsBubble(entry, true /* isBubble */, true /* autoExpand */);
+                }
+            }
+        } else {
+            // Wait until we're unlocked to expand, so that the user can see the expand animation
+            // and also to work around bugs with expansion animation + shade unlock happening at the
+            // same time.
+            mNotifEntryToExpandOnShadeUnlock = entry;
+        }
+    }
+
+    /**
+     * Adds or updates a bubble associated with the provided notification entry.
+     *
+     * @param notif the notification associated with this bubble.
+     */
+    @VisibleForTesting
+    public void updateBubble(BubbleEntry notif) {
+        updateBubble(notif, false /* suppressFlyout */, true /* showInShade */);
+    }
+
+    /**
+     * Fills the overflow bubbles by loading them from disk.
+     */
+    void loadOverflowBubblesFromDisk() {
+        if (!mBubbleData.getOverflowBubbles().isEmpty() || mOverflowDataLoaded) {
+            // we don't need to load overflow bubbles from disk if it is already in memory
+            return;
+        }
+        mOverflowDataLoaded = true;
+        mDataRepository.loadBubbles((bubbles) -> {
+            bubbles.forEach(bubble -> {
+                if (mBubbleData.hasAnyBubbleWithKey(bubble.getKey())) {
+                    // if the bubble is already active, there's no need to push it to overflow
+                    return;
+                }
+                bubble.inflate((b) -> mBubbleData.overflowBubble(DISMISS_AGED, bubble),
+                        mContext, this, mStackView, mBubbleIconFactory, true /* skipInflation */);
+            });
+            return null;
+        });
+    }
+
+    /**
+     * Adds or updates a bubble associated with the provided notification entry.
+     *
+     * @param notif the notification associated with this bubble.
+     * @param suppressFlyout this bubble suppress flyout or not.
+     * @param showInShade this bubble show in shade or not.
+     */
+    @VisibleForTesting
+    public void updateBubble(BubbleEntry notif, boolean suppressFlyout, boolean showInShade) {
+        // If this is an interruptive notif, mark that it's interrupted
+        mSysuiProxy.setNotificationInterruption(notif.getKey());
+        if (!notif.getRanking().visuallyInterruptive()
+                && (notif.getBubbleMetadata() != null
+                    && !notif.getBubbleMetadata().getAutoExpandBubble())
+                && mBubbleData.hasOverflowBubbleWithKey(notif.getKey())) {
+            // Update the bubble but don't promote it out of overflow
+            Bubble b = mBubbleData.getOverflowBubbleWithKey(notif.getKey());
+            b.setEntry(notif);
+        } else {
+            Bubble bubble = mBubbleData.getOrCreateBubble(notif, null /* persistedBubble */);
+            inflateAndAdd(bubble, suppressFlyout, showInShade);
+        }
+    }
+
+    void inflateAndAdd(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
+        // Lazy init stack view when a bubble is created
+        ensureStackViewCreated();
+        bubble.setInflateSynchronously(mInflateSynchronously);
+        bubble.inflate(b -> mBubbleData.notificationEntryUpdated(b, suppressFlyout, showInShade),
+                mContext, this, mStackView, mBubbleIconFactory, false /* skipInflation */);
+    }
+
+    /**
+     * Removes the bubble with the given key.
+     * <p>
+     * Must be called from the main thread.
+     */
+    @VisibleForTesting
+    @MainThread
+    public void removeBubble(String key, int reason) {
+        if (mBubbleData.hasAnyBubbleWithKey(key)) {
+            mBubbleData.dismissBubbleWithKey(key, reason);
+        }
+    }
+
+    @Override
+    public void onEntryAdded(BubbleEntry entry) {
+        if (canLaunchInActivityView(mContext, entry)) {
+            updateBubble(entry);
+        }
+    }
+
+    @Override
+    public void onEntryUpdated(BubbleEntry entry, boolean shouldBubbleUp) {
+        // shouldBubbleUp checks canBubble & for bubble metadata
+        boolean shouldBubble = shouldBubbleUp && canLaunchInActivityView(mContext, entry);
+        if (!shouldBubble && mBubbleData.hasAnyBubbleWithKey(entry.getKey())) {
+            // It was previously a bubble but no longer a bubble -- lets remove it
+            removeBubble(entry.getKey(), DISMISS_NO_LONGER_BUBBLE);
+        } else if (shouldBubble && entry.isBubble()) {
+            updateBubble(entry);
+        }
+    }
+
+    @Override
+    public void onEntryRemoved(BubbleEntry entry) {
+        if (isSummaryOfBubbles(entry)) {
+            final String groupKey = entry.getStatusBarNotification().getGroupKey();
+            mBubbleData.removeSuppressedSummary(groupKey);
+
+            // Remove any associated bubble children with the summary
+            final List<Bubble> bubbleChildren = getBubblesInGroup(groupKey);
+            for (int i = 0; i < bubbleChildren.size(); i++) {
+                removeBubble(bubbleChildren.get(i).getKey(), DISMISS_GROUP_CANCELLED);
+            }
+        } else {
+            removeBubble(entry.getKey(), DISMISS_NOTIF_CANCEL);
+        }
+    }
+
+    @Override
+    public void onRankingUpdated(RankingMap rankingMap) {
+        if (mTmpRanking == null) {
+            mTmpRanking = new NotificationListenerService.Ranking();
+        }
+        String[] orderedKeys = rankingMap.getOrderedKeys();
+        for (int i = 0; i < orderedKeys.length; i++) {
+            String key = orderedKeys[i];
+            BubbleEntry entry = mSysuiProxy.getPendingOrActiveEntry(key);
+            rankingMap.getRanking(key, mTmpRanking);
+            boolean isActiveBubble = mBubbleData.hasAnyBubbleWithKey(key);
+            if (isActiveBubble && !mTmpRanking.canBubble()) {
+                // If this entry is no longer allowed to bubble, dismiss with the BLOCKED reason.
+                // This means that the app or channel's ability to bubble has been revoked.
+                mBubbleData.dismissBubbleWithKey(key, DISMISS_BLOCKED);
+            } else if (isActiveBubble && !mSysuiProxy.shouldBubbleUp(key)) {
+                // If this entry is allowed to bubble, but cannot currently bubble up, dismiss it.
+                // This happens when DND is enabled and configured to hide bubbles. Dismissing with
+                // the reason DISMISS_NO_BUBBLE_UP will retain the underlying notification, so that
+                // the bubble will be re-created if shouldBubbleUp returns true.
+                mBubbleData.dismissBubbleWithKey(key, DISMISS_NO_BUBBLE_UP);
+            } else if (entry != null && mTmpRanking.isBubble() && !isActiveBubble) {
+                entry.setFlagBubble(true);
+                onEntryUpdated(entry, true /* shouldBubbleUp */);
+            }
+        }
+    }
+
+    /**
+     * Retrieves any bubbles that are part of the notification group represented by the provided
+     * group key.
+     */
+    private ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey) {
+        ArrayList<Bubble> bubbleChildren = new ArrayList<>();
+        if (groupKey == null) {
+            return bubbleChildren;
+        }
+        for (Bubble bubble : mBubbleData.getActiveBubbles()) {
+            final BubbleEntry entry = mSysuiProxy.getPendingOrActiveEntry(bubble.getKey());
+            if (entry != null && groupKey.equals(entry.getStatusBarNotification().getGroupKey())) {
+                bubbleChildren.add(bubble);
+            }
+        }
+        return bubbleChildren;
+    }
+
+    private void setIsBubble(@NonNull final BubbleEntry entry, final boolean isBubble,
+            final boolean autoExpand) {
+        Objects.requireNonNull(entry);
+        entry.setFlagBubble(isBubble);
+        try {
+            int flags = 0;
+            if (autoExpand) {
+                flags = Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
+                flags |= Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE;
+            }
+            mBarService.onNotificationBubbleChanged(entry.getKey(), isBubble, flags);
+        } catch (RemoteException e) {
+            // Bad things have happened
+        }
+    }
+
+    private void setIsBubble(@NonNull final Bubble b, final boolean isBubble) {
+        Objects.requireNonNull(b);
+        b.setIsBubble(isBubble);
+        final BubbleEntry entry = mSysuiProxy.getPendingOrActiveEntry(b.getKey());
+        if (entry != null) {
+            // Updating the entry to be a bubble will trigger our normal update flow
+            setIsBubble(entry, isBubble, b.shouldAutoExpand());
+        } else if (isBubble) {
+            // If bubble doesn't exist, it's a persisted bubble so we need to add it to the
+            // stack ourselves
+            Bubble bubble = mBubbleData.getOrCreateBubble(null, b /* persistedBubble */);
+            inflateAndAdd(bubble, bubble.shouldAutoExpand() /* suppressFlyout */,
+                    !bubble.shouldAutoExpand() /* showInShade */);
+        }
+    }
+
+    @SuppressWarnings("FieldCanBeLocal")
+    private final BubbleData.Listener mBubbleDataListener = new BubbleData.Listener() {
+
+        @Override
+        public void applyUpdate(BubbleData.Update update) {
+            ensureStackViewCreated();
+
+            // Lazy load overflow bubbles from disk
+            loadOverflowBubblesFromDisk();
+
+            mStackView.updateOverflowButtonDot();
+
+            // Update bubbles in overflow.
+            if (mOverflowListener != null) {
+                mOverflowListener.applyUpdate(update);
+            }
+
+            // Collapsing? Do this first before remaining steps.
+            if (update.expandedChanged && !update.expanded) {
+                mStackView.setExpanded(false);
+                mSysuiProxy.requestNotificationShadeTopUi(false, TAG);
+            }
+
+            // Do removals, if any.
+            ArrayList<Pair<Bubble, Integer>> removedBubbles =
+                    new ArrayList<>(update.removedBubbles);
+            ArrayList<Bubble> bubblesToBeRemovedFromRepository = new ArrayList<>();
+            for (Pair<Bubble, Integer> removed : removedBubbles) {
+                final Bubble bubble = removed.first;
+                @DismissReason final int reason = removed.second;
+
+                if (mStackView != null) {
+                    mStackView.removeBubble(bubble);
+                }
+
+                // Leave the notification in place if we're dismissing due to user switching, or
+                // because DND is suppressing the bubble. In both of those cases, we need to be able
+                // to restore the bubble from the notification later.
+                if (reason == DISMISS_USER_CHANGED || reason == DISMISS_NO_BUBBLE_UP) {
+                    continue;
+                }
+                if (reason == DISMISS_NOTIF_CANCEL) {
+                    bubblesToBeRemovedFromRepository.add(bubble);
+                }
+                if (!mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+                    if (!mBubbleData.hasOverflowBubbleWithKey(bubble.getKey())
+                            && (!bubble.showInShade()
+                            || reason == DISMISS_NOTIF_CANCEL
+                            || reason == DISMISS_GROUP_CANCELLED)) {
+                        // The bubble is now gone & the notification is hidden from the shade, so
+                        // time to actually remove it
+                        mSysuiProxy.notifyRemoveNotification(bubble.getKey(), REASON_CANCEL);
+                    } else {
+                        if (bubble.isBubble()) {
+                            setIsBubble(bubble, false /* isBubble */);
+                        }
+                        mSysuiProxy.updateNotificationBubbleButton(bubble.getKey());
+                    }
+
+                }
+                final BubbleEntry entry = mSysuiProxy.getPendingOrActiveEntry(bubble.getKey());
+                if (entry != null) {
+                    final String groupKey = entry.getStatusBarNotification().getGroupKey();
+                    if (getBubblesInGroup(groupKey).isEmpty()) {
+                        // Time to potentially remove the summary
+                        mSysuiProxy.notifyMaybeCancelSummary(bubble.getKey());
+                    }
+                }
+            }
+            mDataRepository.removeBubbles(mCurrentUserId, bubblesToBeRemovedFromRepository);
+
+            if (update.addedBubble != null && mStackView != null) {
+                mDataRepository.addBubble(mCurrentUserId, update.addedBubble);
+                mStackView.addBubble(update.addedBubble);
+            }
+
+            if (update.updatedBubble != null && mStackView != null) {
+                mStackView.updateBubble(update.updatedBubble);
+            }
+
+            // At this point, the correct bubbles are inflated in the stack.
+            // Make sure the order in bubble data is reflected in bubble row.
+            if (update.orderChanged && mStackView != null) {
+                mDataRepository.addBubbles(mCurrentUserId, update.bubbles);
+                mStackView.updateBubbleOrder(update.bubbles);
+            }
+
+            if (update.selectionChanged && mStackView != null) {
+                mStackView.setSelectedBubble(update.selectedBubble);
+                if (update.selectedBubble != null) {
+                    mSysuiProxy.updateNotificationSuppression(update.selectedBubble.getKey());
+                }
+            }
+
+            // Expanding? Apply this last.
+            if (update.expandedChanged && update.expanded) {
+                if (mStackView != null) {
+                    mStackView.setExpanded(true);
+                    mSysuiProxy.requestNotificationShadeTopUi(true, TAG);
+                }
+            }
+
+            mSysuiProxy.notifyInvalidateNotifications("BubbleData.Listener.applyUpdate");
+            updateStack();
+        }
+    };
+
+    @Override
+    public boolean handleDismissalInterception(BubbleEntry entry,
+            @Nullable List<BubbleEntry> children, IntConsumer removeCallback) {
+        if (isSummaryOfBubbles(entry)) {
+            handleSummaryDismissalInterception(entry, children, removeCallback);
+        } else {
+            Bubble bubble = mBubbleData.getBubbleInStackWithKey(entry.getKey());
+            if (bubble == null || !entry.isBubble()) {
+                bubble = mBubbleData.getOverflowBubbleWithKey(entry.getKey());
+            }
+            if (bubble == null) {
+                return false;
+            }
+            bubble.setSuppressNotification(true);
+            bubble.setShowDot(false /* show */);
+        }
+        // Update the shade
+        mSysuiProxy.notifyInvalidateNotifications("BubbleController.handleDismissalInterception");
+        return true;
+    }
+
+    private boolean isSummaryOfBubbles(BubbleEntry entry) {
+        String groupKey = entry.getStatusBarNotification().getGroupKey();
+        ArrayList<Bubble> bubbleChildren = getBubblesInGroup(groupKey);
+        boolean isSuppressedSummary = (mBubbleData.isSummarySuppressed(groupKey)
+                && mBubbleData.getSummaryKey(groupKey).equals(entry.getKey()));
+        boolean isSummary = entry.getStatusBarNotification().getNotification().isGroupSummary();
+        return (isSuppressedSummary || isSummary) && !bubbleChildren.isEmpty();
+    }
+
+    private void handleSummaryDismissalInterception(
+            BubbleEntry summary, @Nullable List<BubbleEntry> children, IntConsumer removeCallback) {
+        if (children != null) {
+            for (int i = 0; i < children.size(); i++) {
+                BubbleEntry child = children.get(i);
+                if (mBubbleData.hasAnyBubbleWithKey(child.getKey())) {
+                    // Suppress the bubbled child
+                    // As far as group manager is concerned, once a child is no longer shown
+                    // in the shade, it is essentially removed.
+                    Bubble bubbleChild = mBubbleData.getAnyBubbleWithkey(child.getKey());
+                    if (bubbleChild != null) {
+                        mSysuiProxy.removeNotificationEntry(bubbleChild.getKey());
+                        bubbleChild.setSuppressNotification(true);
+                        bubbleChild.setShowDot(false /* show */);
+                    }
+                } else {
+                    // non-bubbled children can be removed
+                    removeCallback.accept(i);
+                }
+            }
+        }
+
+        // And since all children are removed, remove the summary.
+        removeCallback.accept(-1);
+
+        // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated
+        mBubbleData.addSummaryToSuppress(summary.getStatusBarNotification().getGroupKey(),
+                summary.getKey());
+    }
+
+    /**
+     * Updates the visibility of the bubbles based on current state.
+     * Does not un-bubble, just hides or un-hides.
+     * Updates stack description for TalkBack focus.
+     */
+    public void updateStack() {
+        if (mStackView == null) {
+            return;
+        }
+
+        if (!mIsStatusBarShade) {
+            // Bubbles don't appear over the locked shade.
+            mStackView.setVisibility(INVISIBLE);
+        } else if (hasBubbles()) {
+            // If we're unlocked, show the stack if we have bubbles. If we don't have bubbles, the
+            // stack will be set to INVISIBLE in onAllBubblesAnimatedOut after the bubbles animate
+            // out.
+            mStackView.setVisibility(VISIBLE);
+        }
+
+        mStackView.updateContentDescription();
+    }
+
+    /**
+     * The task id of the expanded view, if the stack is expanded and not occluded by the
+     * status bar, otherwise returns {@link ActivityTaskManager#INVALID_TASK_ID}.
+     */
+    private int getExpandedTaskId() {
+        if (mStackView == null) {
+            return INVALID_TASK_ID;
+        }
+        final BubbleViewProvider expandedViewProvider = mStackView.getExpandedBubble();
+        if (expandedViewProvider != null && isStackExpanded()
+                && !mStackView.isExpansionAnimating()
+                && !mSysuiProxy.isNotificationShadeExpand()) {
+            return expandedViewProvider.getTaskId();
+        }
+        return INVALID_TASK_ID;
+    }
+
+    @VisibleForTesting
+    public BubbleStackView getStackView() {
+        return mStackView;
+    }
+
+    /**
+     * Description of current bubble state.
+     */
+    @Override
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println("BubbleController state:");
+        mBubbleData.dump(fd, pw, args);
+        pw.println();
+        if (mStackView != null) {
+            mStackView.dump(fd, pw, args);
+        }
+        pw.println();
+    }
+
+    /**
+     * Whether an intent is properly configured to display in an {@link android.app.ActivityView}.
+     *
+     * Keep checks in sync with NotificationManagerService#canLaunchInActivityView. Typically
+     * that should filter out any invalid bubbles, but should protect SysUI side just in case.
+     *
+     * @param context the context to use.
+     * @param entry the entry to bubble.
+     */
+    static boolean canLaunchInActivityView(Context context, BubbleEntry entry) {
+        PendingIntent intent = entry.getBubbleMetadata() != null
+                ? entry.getBubbleMetadata().getIntent()
+                : null;
+        if (entry.getBubbleMetadata() != null
+                && entry.getBubbleMetadata().getShortcutId() != null) {
+            return true;
+        }
+        if (intent == null) {
+            Log.w(TAG, "Unable to create bubble -- no intent: " + entry.getKey());
+            return false;
+        }
+        PackageManager packageManager = getPackageManagerForUser(
+                context, entry.getStatusBarNotification().getUser().getIdentifier());
+        ActivityInfo info =
+                intent.getIntent().resolveActivityInfo(packageManager, 0);
+        if (info == null) {
+            Log.w(TAG, "Unable to send as bubble, "
+                    + entry.getKey() + " couldn't find activity info for intent: "
+                    + intent);
+            return false;
+        }
+        if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
+            Log.w(TAG, "Unable to send as bubble, "
+                    + entry.getKey() + " activity is not resizable for intent: "
+                    + intent);
+            return false;
+        }
+        return true;
+    }
+
+    static PackageManager getPackageManagerForUser(Context context, int userId) {
+        Context contextForUser = context;
+        // UserHandle defines special userId as negative values, e.g. USER_ALL
+        if (userId >= 0) {
+            try {
+                // Create a context for the correct user so if a package isn't installed
+                // for user 0 we can still load information about the package.
+                contextForUser =
+                        context.createPackageContextAsUser(context.getPackageName(),
+                                Context.CONTEXT_RESTRICTED,
+                                new UserHandle(userId));
+            } catch (PackageManager.NameNotFoundException e) {
+                // Shouldn't fail to find the package name for system ui.
+            }
+        }
+        return contextForUser.getPackageManager();
+    }
+
+    /** PinnedStackListener that dispatches IME visibility updates to the stack. */
+    //TODO(b/170442945): Better way to do this / insets listener?
+    private class BubblesImeListener extends PinnedStackListenerForwarder.PinnedStackListener {
+        @Override
+        public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+            if (mStackView != null) {
+                mStackView.post(() -> mStackView.onImeVisibilityChanged(imeVisible, imeHeight));
+            }
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java
new file mode 100644
index 0000000..b6a97e2
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java
@@ -0,0 +1,825 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_DATA;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.annotation.NonNull;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.pm.ShortcutInfo;
+import android.util.Log;
+import android.util.Pair;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.FrameworkStatsLog;
+import com.android.wm.shell.R;
+import com.android.wm.shell.bubbles.Bubbles.DismissReason;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Keeps track of active bubbles.
+ */
+public class BubbleData {
+
+    private BubbleLogger mLogger;
+
+    private int mCurrentUserId;
+
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleData" : TAG_BUBBLES;
+
+    private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
+            Comparator.comparing(BubbleData::sortKey).reversed();
+
+    /** Contains information about changes that have been made to the state of bubbles. */
+    static final class Update {
+        boolean expandedChanged;
+        boolean selectionChanged;
+        boolean orderChanged;
+        boolean expanded;
+        @Nullable Bubble selectedBubble;
+        @Nullable Bubble addedBubble;
+        @Nullable Bubble updatedBubble;
+        @Nullable Bubble addedOverflowBubble;
+        @Nullable Bubble removedOverflowBubble;
+        // Pair with Bubble and @DismissReason Integer
+        final List<Pair<Bubble, Integer>> removedBubbles = new ArrayList<>();
+
+        // A read-only view of the bubbles list, changes there will be reflected here.
+        final List<Bubble> bubbles;
+        final List<Bubble> overflowBubbles;
+
+        private Update(List<Bubble> row, List<Bubble> overflow) {
+            bubbles = Collections.unmodifiableList(row);
+            overflowBubbles = Collections.unmodifiableList(overflow);
+        }
+
+        boolean anythingChanged() {
+            return expandedChanged
+                    || selectionChanged
+                    || addedBubble != null
+                    || updatedBubble != null
+                    || !removedBubbles.isEmpty()
+                    || addedOverflowBubble != null
+                    || removedOverflowBubble != null
+                    || orderChanged;
+        }
+
+        void bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason) {
+            removedBubbles.add(new Pair<>(bubbleToRemove, reason));
+        }
+    }
+
+    /**
+     * This interface reports changes to the state and appearance of bubbles which should be applied
+     * as necessary to the UI.
+     */
+    interface Listener {
+        /** Reports changes have have occurred as a result of the most recent operation. */
+        void applyUpdate(Update update);
+    }
+
+    interface TimeSource {
+        long currentTimeMillis();
+    }
+
+    private final Context mContext;
+    /** Bubbles that are actively in the stack. */
+    private final List<Bubble> mBubbles;
+    /** Bubbles that aged out to overflow. */
+    private final List<Bubble> mOverflowBubbles;
+    /** Bubbles that are being loaded but haven't been added to the stack just yet. */
+    private final HashMap<String, Bubble> mPendingBubbles;
+    private Bubble mSelectedBubble;
+    private boolean mShowingOverflow;
+    private boolean mExpanded;
+    private final int mMaxBubbles;
+    private int mMaxOverflowBubbles;
+
+    // State tracked during an operation -- keeps track of what listener events to dispatch.
+    private Update mStateChange;
+
+    private TimeSource mTimeSource = System::currentTimeMillis;
+
+    @Nullable
+    private Listener mListener;
+
+    @Nullable
+    private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
+    private Bubbles.PendingIntentCanceledListener mCancelledListener;
+
+    /**
+     * We track groups with summaries that aren't visibly displayed but still kept around because
+     * the bubble(s) associated with the summary still exist.
+     *
+     * The summary must be kept around so that developers can cancel it (and hence the bubbles
+     * associated with it). This list is used to check if the summary should be hidden from the
+     * shade.
+     *
+     * Key: group key of the notification
+     * Value: key of the notification
+     */
+    private HashMap<String, String> mSuppressedGroupKeys = new HashMap<>();
+
+    public BubbleData(Context context, BubbleLogger bubbleLogger) {
+        mContext = context;
+        mLogger = bubbleLogger;
+        mBubbles = new ArrayList<>();
+        mOverflowBubbles = new ArrayList<>();
+        mPendingBubbles = new HashMap<>();
+        mStateChange = new Update(mBubbles, mOverflowBubbles);
+        mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
+        mMaxOverflowBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_overflow);
+    }
+
+    public void setSuppressionChangedListener(
+            Bubbles.NotificationSuppressionChangedListener listener) {
+        mSuppressionListener = listener;
+    }
+
+    public void setPendingIntentCancelledListener(
+            Bubbles.PendingIntentCanceledListener listener) {
+        mCancelledListener = listener;
+    }
+
+    public boolean hasBubbles() {
+        return !mBubbles.isEmpty();
+    }
+
+    public boolean isExpanded() {
+        return mExpanded;
+    }
+
+    public boolean hasAnyBubbleWithKey(String key) {
+        return hasBubbleInStackWithKey(key) || hasOverflowBubbleWithKey(key);
+    }
+
+    public boolean hasBubbleInStackWithKey(String key) {
+        return getBubbleInStackWithKey(key) != null;
+    }
+
+    public boolean hasOverflowBubbleWithKey(String key) {
+        return getOverflowBubbleWithKey(key) != null;
+    }
+
+    @Nullable
+    public Bubble getSelectedBubble() {
+        return mSelectedBubble;
+    }
+
+    /** Return a read-only current active bubble lists. */
+    public List<Bubble> getActiveBubbles() {
+        return Collections.unmodifiableList(mBubbles);
+    }
+
+    public void setExpanded(boolean expanded) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "setExpanded: " + expanded);
+        }
+        setExpandedInternal(expanded);
+        dispatchPendingChanges();
+    }
+
+    public void setSelectedBubble(Bubble bubble) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "setSelectedBubble: " + bubble);
+        }
+        setSelectedBubbleInternal(bubble);
+        dispatchPendingChanges();
+    }
+
+    void setShowingOverflow(boolean showingOverflow) {
+        mShowingOverflow = showingOverflow;
+    }
+
+    /**
+     * Constructs a new bubble or returns an existing one. Does not add new bubbles to
+     * bubble data, must go through {@link #notificationEntryUpdated(Bubble, boolean, boolean)}
+     * for that.
+     *
+     * @param entry The notification entry to use, only null if it's a bubble being promoted from
+     *              the overflow that was persisted over reboot.
+     * @param persistedBubble The bubble to use, only non-null if it's a bubble being promoted from
+     *              the overflow that was persisted over reboot.
+     */
+    public Bubble getOrCreateBubble(BubbleEntry entry, Bubble persistedBubble) {
+        String key = persistedBubble != null ? persistedBubble.getKey() : entry.getKey();
+        Bubble bubbleToReturn = getBubbleInStackWithKey(key);
+
+        if (bubbleToReturn == null) {
+            bubbleToReturn = getOverflowBubbleWithKey(key);
+            if (bubbleToReturn != null) {
+                // Promoting from overflow
+                mOverflowBubbles.remove(bubbleToReturn);
+            } else if (mPendingBubbles.containsKey(key)) {
+                // Update while it was pending
+                bubbleToReturn = mPendingBubbles.get(key);
+            } else if (entry != null) {
+                // New bubble
+                bubbleToReturn = new Bubble(entry, mSuppressionListener, mCancelledListener);
+            } else {
+                // Persisted bubble being promoted
+                bubbleToReturn = persistedBubble;
+            }
+        }
+
+        if (entry != null) {
+            bubbleToReturn.setEntry(entry);
+        }
+        mPendingBubbles.put(key, bubbleToReturn);
+        return bubbleToReturn;
+    }
+
+    /**
+     * When this method is called it is expected that all info in the bubble has completed loading.
+     * @see Bubble#inflate(BubbleViewInfoTask.Callback, Context,
+     * BubbleStackView, BubbleIconFactory, boolean).
+     */
+    void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "notificationEntryUpdated: " + bubble);
+        }
+        mPendingBubbles.remove(bubble.getKey()); // No longer pending once we're here
+        Bubble prevBubble = getBubbleInStackWithKey(bubble.getKey());
+        suppressFlyout |= !bubble.isVisuallyInterruptive();
+
+        if (prevBubble == null) {
+            // Create a new bubble
+            bubble.setSuppressFlyout(suppressFlyout);
+            doAdd(bubble);
+            trim();
+        } else {
+            // Updates an existing bubble
+            bubble.setSuppressFlyout(suppressFlyout);
+            // If there is no flyout, we probably shouldn't show the bubble at the top
+            doUpdate(bubble, !suppressFlyout /* reorder */);
+        }
+
+        if (bubble.shouldAutoExpand()) {
+            bubble.setShouldAutoExpand(false);
+            setSelectedBubbleInternal(bubble);
+            if (!mExpanded) {
+                setExpandedInternal(true);
+            }
+        }
+
+        boolean isBubbleExpandedAndSelected = mExpanded && mSelectedBubble == bubble;
+        boolean suppress = isBubbleExpandedAndSelected || !showInShade || !bubble.showInShade();
+        bubble.setSuppressNotification(suppress);
+        bubble.setShowDot(!isBubbleExpandedAndSelected /* show */);
+
+        dispatchPendingChanges();
+    }
+
+    /**
+     * Dismisses the bubble with the matching key, if it exists.
+     */
+    public void dismissBubbleWithKey(String key, @DismissReason int reason) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "notificationEntryRemoved: key=" + key + " reason=" + reason);
+        }
+        doRemove(key, reason);
+        dispatchPendingChanges();
+    }
+
+    /**
+     * Adds a group key indicating that the summary for this group should be suppressed.
+     *
+     * @param groupKey the group key of the group whose summary should be suppressed.
+     * @param notifKey the notification entry key of that summary.
+     */
+    void addSummaryToSuppress(String groupKey, String notifKey) {
+        mSuppressedGroupKeys.put(groupKey, notifKey);
+    }
+
+    /**
+     * Retrieves the notif entry key of the summary associated with the provided group key.
+     *
+     * @param groupKey the group to look up
+     * @return the key for the notification that is the summary of this group.
+     */
+    String getSummaryKey(String groupKey) {
+        return mSuppressedGroupKeys.get(groupKey);
+    }
+
+    /**
+     * Removes a group key indicating that summary for this group should no longer be suppressed.
+     */
+    void removeSuppressedSummary(String groupKey) {
+        mSuppressedGroupKeys.remove(groupKey);
+    }
+
+    /**
+     * Whether the summary for the provided group key is suppressed.
+     */
+    @VisibleForTesting
+    public boolean isSummarySuppressed(String groupKey) {
+        return mSuppressedGroupKeys.containsKey(groupKey);
+    }
+
+    /**
+     * Removes bubbles from the given package whose shortcut are not in the provided list of valid
+     * shortcuts.
+     */
+    public void removeBubblesWithInvalidShortcuts(
+            String packageName, List<ShortcutInfo> validShortcuts, int reason) {
+
+        final Set<String> validShortcutIds = new HashSet<String>();
+        for (ShortcutInfo info : validShortcuts) {
+            validShortcutIds.add(info.getId());
+        }
+
+        final Predicate<Bubble> invalidBubblesFromPackage = bubble -> {
+            final boolean bubbleIsFromPackage = packageName.equals(bubble.getPackageName());
+            final boolean isShortcutBubble = bubble.hasMetadataShortcutId();
+            if (!bubbleIsFromPackage || !isShortcutBubble) {
+                return false;
+            }
+            final boolean hasShortcutIdAndValidShortcut =
+                    bubble.hasMetadataShortcutId()
+                            && bubble.getShortcutInfo() != null
+                            && bubble.getShortcutInfo().isEnabled()
+                            && validShortcutIds.contains(bubble.getShortcutInfo().getId());
+            return bubbleIsFromPackage && !hasShortcutIdAndValidShortcut;
+        };
+
+        final Consumer<Bubble> removeBubble = bubble ->
+                dismissBubbleWithKey(bubble.getKey(), reason);
+
+        performActionOnBubblesMatching(getBubbles(), invalidBubblesFromPackage, removeBubble);
+        performActionOnBubblesMatching(
+                getOverflowBubbles(), invalidBubblesFromPackage, removeBubble);
+    }
+
+    /** Dismisses all bubbles from the given package. */
+    public void removeBubblesWithPackageName(String packageName, int reason) {
+        final Predicate<Bubble> bubbleMatchesPackage = bubble ->
+                bubble.getPackageName().equals(packageName);
+
+        final Consumer<Bubble> removeBubble = bubble ->
+                dismissBubbleWithKey(bubble.getKey(), reason);
+
+        performActionOnBubblesMatching(getBubbles(), bubbleMatchesPackage, removeBubble);
+        performActionOnBubblesMatching(getOverflowBubbles(), bubbleMatchesPackage, removeBubble);
+    }
+
+    private void doAdd(Bubble bubble) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "doAdd: " + bubble);
+        }
+        mBubbles.add(0, bubble);
+        mStateChange.addedBubble = bubble;
+        // Adding the first bubble doesn't change the order
+        mStateChange.orderChanged = mBubbles.size() > 1;
+        if (!isExpanded()) {
+            setSelectedBubbleInternal(mBubbles.get(0));
+        }
+    }
+
+    private void trim() {
+        if (mBubbles.size() > mMaxBubbles) {
+            mBubbles.stream()
+                    // sort oldest first (ascending lastActivity)
+                    .sorted(Comparator.comparingLong(Bubble::getLastActivity))
+                    // skip the selected bubble
+                    .filter((b) -> !b.equals(mSelectedBubble))
+                    .findFirst()
+                    .ifPresent((b) -> doRemove(b.getKey(), Bubbles.DISMISS_AGED));
+        }
+    }
+
+    private void doUpdate(Bubble bubble, boolean reorder) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "doUpdate: " + bubble);
+        }
+        mStateChange.updatedBubble = bubble;
+        if (!isExpanded() && reorder) {
+            int prevPos = mBubbles.indexOf(bubble);
+            mBubbles.remove(bubble);
+            mBubbles.add(0, bubble);
+            mStateChange.orderChanged = prevPos != 0;
+            setSelectedBubbleInternal(mBubbles.get(0));
+        }
+    }
+
+    /** Runs the given action on Bubbles that match the given predicate. */
+    private void performActionOnBubblesMatching(
+            List<Bubble> bubbles, Predicate<Bubble> predicate, Consumer<Bubble> action) {
+        final List<Bubble> matchingBubbles = new ArrayList<>();
+        for (Bubble bubble : bubbles) {
+            if (predicate.test(bubble)) {
+                matchingBubbles.add(bubble);
+            }
+        }
+
+        for (Bubble matchingBubble : matchingBubbles) {
+            action.accept(matchingBubble);
+        }
+    }
+
+    private void doRemove(String key, @DismissReason int reason) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "doRemove: " + key);
+        }
+        //  If it was pending remove it
+        if (mPendingBubbles.containsKey(key)) {
+            mPendingBubbles.remove(key);
+        }
+        int indexToRemove = indexForKey(key);
+        if (indexToRemove == -1) {
+            if (hasOverflowBubbleWithKey(key)
+                    && (reason == Bubbles.DISMISS_NOTIF_CANCEL
+                        || reason == Bubbles.DISMISS_GROUP_CANCELLED
+                        || reason == Bubbles.DISMISS_NO_LONGER_BUBBLE
+                        || reason == Bubbles.DISMISS_BLOCKED
+                        || reason == Bubbles.DISMISS_SHORTCUT_REMOVED
+                        || reason == Bubbles.DISMISS_PACKAGE_REMOVED)) {
+
+                Bubble b = getOverflowBubbleWithKey(key);
+                if (DEBUG_BUBBLE_DATA) {
+                    Log.d(TAG, "Cancel overflow bubble: " + b);
+                }
+                if (b != null) {
+                    b.stopInflation();
+                }
+                mLogger.logOverflowRemove(b, reason);
+                mOverflowBubbles.remove(b);
+                mStateChange.bubbleRemoved(b, reason);
+                mStateChange.removedOverflowBubble = b;
+            }
+            return;
+        }
+        Bubble bubbleToRemove = mBubbles.get(indexToRemove);
+        bubbleToRemove.stopInflation();
+        if (mBubbles.size() == 1) {
+            // Going to become empty, handle specially.
+            setExpandedInternal(false);
+            // Don't use setSelectedBubbleInternal because we don't want to trigger an applyUpdate
+            mSelectedBubble = null;
+        }
+        if (indexToRemove < mBubbles.size() - 1) {
+            // Removing anything but the last bubble means positions will change.
+            mStateChange.orderChanged = true;
+        }
+        mBubbles.remove(indexToRemove);
+        mStateChange.bubbleRemoved(bubbleToRemove, reason);
+        if (!isExpanded()) {
+            mStateChange.orderChanged |= repackAll();
+        }
+
+        overflowBubble(reason, bubbleToRemove);
+
+        // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
+        if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
+            // Move selection to the new bubble at the same position.
+            int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
+            Bubble newSelected = mBubbles.get(newIndex);
+            setSelectedBubbleInternal(newSelected);
+        }
+        maybeSendDeleteIntent(reason, bubbleToRemove);
+    }
+
+    void overflowBubble(@DismissReason int reason, Bubble bubble) {
+        if (bubble.getPendingIntentCanceled()
+                || !(reason == Bubbles.DISMISS_AGED
+                || reason == Bubbles.DISMISS_USER_GESTURE)) {
+            return;
+        }
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "Overflowing: " + bubble);
+        }
+        mLogger.logOverflowAdd(bubble, reason);
+        mOverflowBubbles.add(0, bubble);
+        mStateChange.addedOverflowBubble = bubble;
+        bubble.stopInflation();
+        if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
+            // Remove oldest bubble.
+            Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
+            if (DEBUG_BUBBLE_DATA) {
+                Log.d(TAG, "Overflow full. Remove: " + oldest);
+            }
+            mStateChange.bubbleRemoved(oldest, Bubbles.DISMISS_OVERFLOW_MAX_REACHED);
+            mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_MAX_REACHED);
+            mOverflowBubbles.remove(oldest);
+            mStateChange.removedOverflowBubble = oldest;
+        }
+    }
+
+    public void dismissAll(@DismissReason int reason) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "dismissAll: reason=" + reason);
+        }
+        if (mBubbles.isEmpty()) {
+            return;
+        }
+        setExpandedInternal(false);
+        setSelectedBubbleInternal(null);
+        while (!mBubbles.isEmpty()) {
+            doRemove(mBubbles.get(0).getKey(), reason);
+        }
+        dispatchPendingChanges();
+    }
+
+    private void dispatchPendingChanges() {
+        if (mListener != null && mStateChange.anythingChanged()) {
+            mListener.applyUpdate(mStateChange);
+        }
+        mStateChange = new Update(mBubbles, mOverflowBubbles);
+    }
+
+    /**
+     * Requests a change to the selected bubble.
+     *
+     * @param bubble the new selected bubble
+     */
+    private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
+        }
+        if (!mShowingOverflow && Objects.equals(bubble, mSelectedBubble)) {
+            return;
+        }
+        // Otherwise, if we are showing the overflow menu, return to the previously selected bubble.
+
+        if (bubble != null && !mBubbles.contains(bubble) && !mOverflowBubbles.contains(bubble)) {
+            Log.e(TAG, "Cannot select bubble which doesn't exist!"
+                    + " (" + bubble + ") bubbles=" + mBubbles);
+            return;
+        }
+        if (mExpanded && bubble != null) {
+            bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
+        }
+        mSelectedBubble = bubble;
+        mStateChange.selectedBubble = bubble;
+        mStateChange.selectionChanged = true;
+    }
+
+    void setCurrentUserId(int uid) {
+        mCurrentUserId = uid;
+    }
+
+    /**
+     * Logs the bubble UI event.
+     *
+     * @param provider The bubble view provider that is being interacted on. Null value indicates
+     *               that the user interaction is not specific to one bubble.
+     * @param action The user interaction enum
+     * @param packageName SystemUI package
+     * @param bubbleCount Number of bubbles in the stack
+     * @param bubbleIndex Index of bubble in the stack
+     * @param normalX Normalized x position of the stack
+     * @param normalY Normalized y position of the stack
+     */
+    void logBubbleEvent(@Nullable BubbleViewProvider provider, int action, String packageName,
+            int bubbleCount, int bubbleIndex, float normalX, float normalY) {
+        if (provider == null) {
+            mLogger.logStackUiChanged(packageName, action, bubbleCount, normalX, normalY);
+        } else if (provider.getKey().equals(BubbleOverflow.KEY)) {
+            if (action == FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED) {
+                mLogger.logShowOverflow(packageName, mCurrentUserId);
+            }
+        } else {
+            mLogger.logBubbleUiChanged((Bubble) provider, packageName, action, bubbleCount, normalX,
+                    normalY, bubbleIndex);
+        }
+    }
+
+    /**
+     * Requests a change to the expanded state.
+     *
+     * @param shouldExpand the new requested state
+     */
+    private void setExpandedInternal(boolean shouldExpand) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
+        }
+        if (mExpanded == shouldExpand) {
+            return;
+        }
+        if (shouldExpand) {
+            if (mBubbles.isEmpty()) {
+                Log.e(TAG, "Attempt to expand stack when empty!");
+                return;
+            }
+            if (mSelectedBubble == null) {
+                Log.e(TAG, "Attempt to expand stack without selected bubble!");
+                return;
+            }
+            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
+            mStateChange.orderChanged |= repackAll();
+        } else if (!mBubbles.isEmpty()) {
+            // Apply ordering and grouping rules from expanded -> collapsed, then save
+            // the result.
+            mStateChange.orderChanged |= repackAll();
+            // Save the state which should be returned to when expanded (with no other changes)
+
+            if (mShowingOverflow) {
+                // Show previously selected bubble instead of overflow menu on next expansion.
+                setSelectedBubbleInternal(mSelectedBubble);
+            }
+            if (mBubbles.indexOf(mSelectedBubble) > 0) {
+                // Move the selected bubble to the top while collapsed.
+                int index = mBubbles.indexOf(mSelectedBubble);
+                if (index != 0) {
+                    mBubbles.remove(mSelectedBubble);
+                    mBubbles.add(0, mSelectedBubble);
+                    mStateChange.orderChanged = true;
+                }
+            }
+        }
+        mExpanded = shouldExpand;
+        mStateChange.expanded = shouldExpand;
+        mStateChange.expandedChanged = true;
+    }
+
+    private static long sortKey(Bubble bubble) {
+        return bubble.getLastActivity();
+    }
+
+    /**
+     * This applies a full sort and group pass to all existing bubbles.
+     * Bubbles are sorted by lastUpdated descending.
+     *
+     * @return true if the position of any bubbles changed as a result
+     */
+    private boolean repackAll() {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "repackAll()");
+        }
+        if (mBubbles.isEmpty()) {
+            return false;
+        }
+        List<Bubble> repacked = new ArrayList<>(mBubbles.size());
+        // Add bubbles, freshest to oldest
+        mBubbles.stream()
+                .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
+                .forEachOrdered(repacked::add);
+        if (repacked.equals(mBubbles)) {
+            return false;
+        }
+        mBubbles.clear();
+        mBubbles.addAll(repacked);
+        return true;
+    }
+
+    private void maybeSendDeleteIntent(@DismissReason int reason, @NonNull final Bubble bubble) {
+        if (reason != Bubbles.DISMISS_USER_GESTURE) return;
+        PendingIntent deleteIntent = bubble.getDeleteIntent();
+        if (deleteIntent == null) return;
+        try {
+            deleteIntent.send();
+        } catch (PendingIntent.CanceledException e) {
+            Log.w(TAG, "Failed to send delete intent for bubble with key: " + bubble.getKey());
+        }
+    }
+
+    private int indexForKey(String key) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubble = mBubbles.get(i);
+            if (bubble.getKey().equals(key)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * The set of bubbles in row.
+     */
+    @VisibleForTesting(visibility = PACKAGE)
+    public List<Bubble> getBubbles() {
+        return Collections.unmodifiableList(mBubbles);
+    }
+
+    /**
+     * The set of bubbles in overflow.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public List<Bubble> getOverflowBubbles() {
+        return Collections.unmodifiableList(mOverflowBubbles);
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    @Nullable
+    Bubble getAnyBubbleWithkey(String key) {
+        Bubble b = getBubbleInStackWithKey(key);
+        if (b == null) {
+            b = getOverflowBubbleWithKey(key);
+        }
+        return b;
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    @Nullable
+    public Bubble getBubbleInStackWithKey(String key) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubble = mBubbles.get(i);
+            if (bubble.getKey().equals(key)) {
+                return bubble;
+            }
+        }
+        return null;
+    }
+
+    @Nullable
+    Bubble getBubbleWithView(View view) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubble = mBubbles.get(i);
+            if (bubble.getIconView() != null && bubble.getIconView().equals(view)) {
+                return bubble;
+            }
+        }
+        return null;
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    public Bubble getOverflowBubbleWithKey(String key) {
+        for (int i = 0; i < mOverflowBubbles.size(); i++) {
+            Bubble bubble = mOverflowBubbles.get(i);
+            if (bubble.getKey().equals(key)) {
+                return bubble;
+            }
+        }
+        return null;
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    void setTimeSource(TimeSource timeSource) {
+        mTimeSource = timeSource;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    /**
+     * Set maximum number of bubbles allowed in overflow.
+     * This method should only be used in tests, not in production.
+     */
+    @VisibleForTesting
+    public void setMaxOverflowBubbles(int maxOverflowBubbles) {
+        mMaxOverflowBubbles = maxOverflowBubbles;
+    }
+
+    /**
+     * Description of current bubble data state.
+     */
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.print("selected: ");
+        pw.println(mSelectedBubble != null
+                ? mSelectedBubble.getKey()
+                : "null");
+        pw.print("expanded: ");
+        pw.println(mExpanded);
+
+        pw.print("stack bubble count:    ");
+        pw.println(mBubbles.size());
+        for (Bubble bubble : mBubbles) {
+            bubble.dump(fd, pw, args);
+        }
+
+        pw.print("overflow bubble count:    ");
+        pw.println(mOverflowBubbles.size());
+        for (Bubble bubble : mOverflowBubbles) {
+            bubble.dump(fd, pw, args);
+        }
+
+        pw.print("summaryKeys: ");
+        pw.println(mSuppressedGroupKeys.size());
+        for (String key : mSuppressedGroupKeys.keySet()) {
+            pw.println("   suppressing: " + key);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt
new file mode 100644
index 0000000..fc565f1
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDataRepository.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.annotation.SuppressLint
+import android.annotation.UserIdInt
+import android.content.Context
+import android.content.pm.LauncherApps
+import android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED
+import android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC
+import android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
+import android.os.UserHandle
+import android.util.Log
+import com.android.wm.shell.bubbles.storage.BubbleEntity
+import com.android.wm.shell.bubbles.storage.BubblePersistentRepository
+import com.android.wm.shell.bubbles.storage.BubbleVolatileRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+
+internal class BubbleDataRepository(context: Context, private val launcherApps: LauncherApps) {
+    private val volatileRepository = BubbleVolatileRepository(launcherApps)
+    private val persistentRepository = BubblePersistentRepository(context)
+
+    private val ioScope = CoroutineScope(Dispatchers.IO)
+    private val uiScope = CoroutineScope(Dispatchers.Main)
+    private var job: Job? = null
+
+    /**
+     * Adds the bubble in memory, then persists the snapshot after adding the bubble to disk
+     * asynchronously.
+     */
+    fun addBubble(@UserIdInt userId: Int, bubble: Bubble) = addBubbles(userId, listOf(bubble))
+
+    /**
+     * Adds the bubble in memory, then persists the snapshot after adding the bubble to disk
+     * asynchronously.
+     */
+    fun addBubbles(@UserIdInt userId: Int, bubbles: List<Bubble>) {
+        if (DEBUG) Log.d(TAG, "adding ${bubbles.size} bubbles")
+        val entities = transform(userId, bubbles).also(volatileRepository::addBubbles)
+        if (entities.isNotEmpty()) persistToDisk()
+    }
+
+    /**
+     * Removes the bubbles from memory, then persists the snapshot to disk asynchronously.
+     */
+    fun removeBubbles(@UserIdInt userId: Int, bubbles: List<Bubble>) {
+        if (DEBUG) Log.d(TAG, "removing ${bubbles.size} bubbles")
+        val entities = transform(userId, bubbles).also(volatileRepository::removeBubbles)
+        if (entities.isNotEmpty()) persistToDisk()
+    }
+
+    private fun transform(userId: Int, bubbles: List<Bubble>): List<BubbleEntity> {
+        return bubbles.mapNotNull { b ->
+            BubbleEntity(
+                    userId,
+                    b.packageName,
+                    b.metadataShortcutId ?: return@mapNotNull null,
+                    b.key,
+                    b.rawDesiredHeight,
+                    b.rawDesiredHeightResId,
+                    b.title
+            )
+        }
+    }
+
+    /**
+     * Persists the bubbles to disk. When being called multiple times, it waits for first ongoing
+     * write operation to finish then run another write operation exactly once.
+     *
+     * e.g.
+     * Job A started -> blocking I/O
+     * Job B started, cancels A, wait for blocking I/O in A finishes
+     * Job C started, cancels B, wait for job B to finish
+     * Job D started, cancels C, wait for job C to finish
+     * Job A completed
+     * Job B resumes and reaches yield() and is then cancelled
+     * Job C resumes and reaches yield() and is then cancelled
+     * Job D resumes and performs another blocking I/O
+     */
+    private fun persistToDisk() {
+        val prev = job
+        job = ioScope.launch {
+            // if there was an ongoing disk I/O operation, they can be cancelled
+            prev?.cancelAndJoin()
+            // check for cancellation before disk I/O
+            yield()
+            // save to disk
+            persistentRepository.persistsToDisk(volatileRepository.bubbles)
+        }
+    }
+
+    /**
+     * Load bubbles from disk.
+     */
+    @SuppressLint("WrongConstant")
+    fun loadBubbles(cb: (List<Bubble>) -> Unit) = ioScope.launch {
+        /**
+         * Load BubbleEntity from disk.
+         * e.g.
+         * [
+         *     BubbleEntity(0, "com.example.messenger", "id-2"),
+         *     BubbleEntity(10, "com.example.chat", "my-id1")
+         *     BubbleEntity(0, "com.example.messenger", "id-1")
+         * ]
+         */
+        val entities = persistentRepository.readFromDisk()
+        volatileRepository.addBubbles(entities)
+        /**
+         * Extract userId/packageName from these entities.
+         * e.g.
+         * [
+         *     ShortcutKey(0, "com.example.messenger"), ShortcutKey(0, "com.example.chat")
+         * ]
+         */
+        val shortcutKeys = entities.map { ShortcutKey(it.userId, it.packageName) }.toSet()
+        /**
+         * Retrieve shortcuts with given userId/packageName combination, then construct a mapping
+         * from the userId/packageName pair to a list of associated ShortcutInfo.
+         * e.g.
+         * {
+         *     ShortcutKey(0, "com.example.messenger") -> [
+         *         ShortcutInfo(userId=0, pkg="com.example.messenger", id="id-0"),
+         *         ShortcutInfo(userId=0, pkg="com.example.messenger", id="id-2")
+         *     ]
+         *     ShortcutKey(10, "com.example.chat") -> [
+         *         ShortcutInfo(userId=10, pkg="com.example.chat", id="id-1"),
+         *         ShortcutInfo(userId=10, pkg="com.example.chat", id="id-3")
+         *     ]
+         * }
+         */
+        val shortcutMap = shortcutKeys.flatMap { key ->
+            launcherApps.getShortcuts(
+                    LauncherApps.ShortcutQuery()
+                            .setPackage(key.pkg)
+                            .setQueryFlags(SHORTCUT_QUERY_FLAG), UserHandle.of(key.userId))
+                    ?: emptyList()
+        }.groupBy { ShortcutKey(it.userId, it.`package`) }
+        // For each entity loaded from xml, find the corresponding ShortcutInfo then convert them
+        // into Bubble.
+        val bubbles = entities.mapNotNull { entity ->
+            shortcutMap[ShortcutKey(entity.userId, entity.packageName)]
+                    ?.firstOrNull { shortcutInfo -> entity.shortcutId == shortcutInfo.id }
+                    ?.let { shortcutInfo -> Bubble(
+                            entity.key,
+                            shortcutInfo,
+                            entity.desiredHeight,
+                            entity.desiredHeightResId,
+                            entity.title
+                    ) }
+        }
+        uiScope.launch { cb(bubbles) }
+    }
+}
+
+data class ShortcutKey(val userId: Int, val pkg: String)
+
+private const val TAG = "BubbleDataRepository"
+private const val DEBUG = false
+private const val SHORTCUT_QUERY_FLAG =
+        FLAG_MATCH_DYNAMIC or FLAG_MATCH_PINNED_BY_ANY_LAUNCHER or FLAG_MATCH_CACHED
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java
new file mode 100644
index 0000000..53f4e87
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.content.Context;
+import android.provider.Settings;
+
+import java.util.List;
+
+/**
+ * Common class for the various debug {@link android.util.Log} output configuration in the Bubbles
+ * package.
+ */
+public class BubbleDebugConfig {
+
+    // All output logs in the Bubbles package use the {@link #TAG_BUBBLES} string for tagging their
+    // log output. This makes it easy to identify the origin of the log message when sifting
+    // through a large amount of log output from multiple sources. However, it also makes trying
+    // to figure-out the origin of a log message while debugging the Bubbles a little painful. By
+    // setting this constant to true, log messages from the Bubbles package will be tagged with
+    // their class names instead fot the generic tag.
+    public static final boolean TAG_WITH_CLASS_NAME = false;
+
+    // Default log tag for the Bubbles package.
+    public static final String TAG_BUBBLES = "Bubbles";
+
+    static final boolean DEBUG_BUBBLE_CONTROLLER = false;
+    static final boolean DEBUG_BUBBLE_DATA = false;
+    static final boolean DEBUG_BUBBLE_STACK_VIEW = false;
+    static final boolean DEBUG_BUBBLE_EXPANDED_VIEW = false;
+    static final boolean DEBUG_EXPERIMENTS = true;
+    static final boolean DEBUG_OVERFLOW = false;
+    static final boolean DEBUG_USER_EDUCATION = false;
+
+    private static final boolean FORCE_SHOW_USER_EDUCATION = false;
+    private static final String FORCE_SHOW_USER_EDUCATION_SETTING =
+            "force_show_bubbles_user_education";
+
+    /**
+     * @return whether we should force show user education for bubbles. Used for debugging & demos.
+     */
+    static boolean forceShowUserEducation(Context context) {
+        boolean forceShow = Settings.Secure.getInt(context.getContentResolver(),
+                FORCE_SHOW_USER_EDUCATION_SETTING, 0) != 0;
+        return FORCE_SHOW_USER_EDUCATION || forceShow;
+    }
+
+    static String formatBubblesString(List<Bubble> bubbles, BubbleViewProvider selected) {
+        StringBuilder sb = new StringBuilder();
+        for (Bubble bubble : bubbles) {
+            if (bubble == null) {
+                sb.append("   <null> !!!!!\n");
+            } else {
+                boolean isSelected = (selected != null
+                        && selected.getKey() != BubbleOverflow.KEY
+                        && bubble == selected);
+                String arrow = isSelected ? "=>" : "  ";
+                sb.append(String.format("%s Bubble{act=%12d, showInShade=%d, key=%s}\n",
+                        arrow,
+                        bubble.getLastActivity(),
+                        (bubble.showInShade() ? 1 : 0),
+                        bubble.getKey()));
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEntry.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEntry.java
new file mode 100644
index 0000000..ff68861
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEntry.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.app.Notification.FLAG_BUBBLE;
+
+import android.app.Notification;
+import android.app.Notification.BubbleMetadata;
+import android.app.NotificationManager.Policy;
+import android.service.notification.NotificationListenerService.Ranking;
+import android.service.notification.StatusBarNotification;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Represents a notification with needed data and flag for bubbles.
+ *
+ * @see Bubble
+ */
+public class BubbleEntry {
+
+    private StatusBarNotification mSbn;
+    private Ranking mRanking;
+
+    private boolean mIsClearable;
+    private boolean mShouldSuppressNotificationDot;
+    private boolean mShouldSuppressNotificationList;
+    private boolean mShouldSuppressPeek;
+
+    public BubbleEntry(@NonNull StatusBarNotification sbn,
+            Ranking ranking, boolean isClearable, boolean shouldSuppressNotificationDot,
+            boolean shouldSuppressNotificationList, boolean shouldSuppressPeek) {
+        mSbn = sbn;
+        mRanking = ranking;
+
+        mIsClearable = isClearable;
+        mShouldSuppressNotificationDot = shouldSuppressNotificationDot;
+        mShouldSuppressNotificationList = shouldSuppressNotificationList;
+        mShouldSuppressPeek = shouldSuppressPeek;
+    }
+
+    /** @return the {@link StatusBarNotification} for this entry. */
+    @NonNull
+    public StatusBarNotification getStatusBarNotification() {
+        return mSbn;
+    }
+
+    /** @return the {@link Ranking} for this entry. */
+    public Ranking getRanking() {
+        return mRanking;
+    }
+
+    /** @return the key in the {@link StatusBarNotification}. */
+    public String getKey() {
+        return mSbn.getKey();
+    }
+
+    /** @return the group key in the {@link StatusBarNotification}. */
+    public String getGroupKey() {
+        return mSbn.getGroupKey();
+    }
+
+    /** @return the {@link BubbleMetadata} in the {@link StatusBarNotification}. */
+    @Nullable
+    public BubbleMetadata getBubbleMetadata() {
+        return getStatusBarNotification().getNotification().getBubbleMetadata();
+    }
+
+    /**
+     * Updates the {@link Notification#FLAG_BUBBLE} flag on this notification to indicate
+     * whether it is a bubble or not. If this entry is set to not bubble, or does not have
+     * the required info to bubble, the flag cannot be set to true.
+     *
+     * @param shouldBubble whether this notification should be flagged as a bubble.
+     * @return true if the value changed.
+     */
+    public boolean setFlagBubble(boolean shouldBubble) {
+        boolean wasBubble = isBubble();
+        if (!shouldBubble) {
+            mSbn.getNotification().flags &= ~FLAG_BUBBLE;
+        } else if (getBubbleMetadata() != null && canBubble()) {
+            // wants to be bubble & can bubble, set flag
+            mSbn.getNotification().flags |= FLAG_BUBBLE;
+        }
+        return wasBubble != isBubble();
+    }
+
+    public boolean isBubble() {
+        return (mSbn.getNotification().flags & FLAG_BUBBLE) != 0;
+    }
+
+    /** @see Ranking#canBubble() */
+    public boolean canBubble() {
+        return mRanking.canBubble();
+    }
+
+    /** @return true if this notification is clearable. */
+    public boolean isClearable() {
+        return mIsClearable;
+    }
+
+    /** @return true if {@link Policy#SUPPRESSED_EFFECT_BADGE} set for this notification. */
+    public boolean shouldSuppressNotificationDot() {
+        return mShouldSuppressNotificationDot;
+    }
+
+    /**
+     * @return true if {@link Policy#SUPPRESSED_EFFECT_NOTIFICATION_LIST}
+     * set for this notification.
+     */
+    public boolean shouldSuppressNotificationList() {
+        return mShouldSuppressNotificationList;
+    }
+
+    /** @return true if {@link Policy#SUPPRESSED_EFFECT_PEEK} set for this notification. */
+    public boolean shouldSuppressPeek() {
+        return mShouldSuppressPeek;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
new file mode 100644
index 0000000..74521c7
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
@@ -0,0 +1,666 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPANDED_VIEW;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+import static com.android.wm.shell.bubbles.BubbleOverflowActivity.EXTRA_BUBBLE_CONTROLLER;
+
+import android.annotation.NonNull;
+import android.annotation.SuppressLint;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.Outline;
+import android.graphics.Rect;
+import android.graphics.drawable.ShapeDrawable;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.SurfaceControl;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.policy.ScreenDecorationsUtils;
+import com.android.wm.shell.R;
+import com.android.wm.shell.TaskView;
+import com.android.wm.shell.common.AlphaOptimizedButton;
+import com.android.wm.shell.common.HandlerExecutor;
+import com.android.wm.shell.common.TriangleShape;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+/**
+ * Container for the expanded bubble view, handles rendering the caret and settings icon.
+ */
+public class BubbleExpandedView extends LinearLayout {
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleExpandedView" : TAG_BUBBLES;
+
+    // The triangle pointing to the expanded view
+    private View mPointerView;
+    private int mPointerMargin;
+    @Nullable private int[] mExpandedViewContainerLocation;
+
+    private AlphaOptimizedButton mSettingsIcon;
+    private TaskView mTaskView;
+
+    private int mTaskId = INVALID_TASK_ID;
+
+    private boolean mImeVisible;
+    private boolean mNeedsNewHeight;
+
+    private int mMinHeight;
+    private int mOverflowHeight;
+    private int mSettingsIconHeight;
+    private int mPointerWidth;
+    private int mPointerHeight;
+    private ShapeDrawable mCurrentPointer;
+    private ShapeDrawable mTopPointer;
+    private ShapeDrawable mLeftPointer;
+    private ShapeDrawable mRightPointer;
+    private int mExpandedViewPadding;
+    private float mCornerRadius = 0f;
+
+    @Nullable private Bubble mBubble;
+    private PendingIntent mPendingIntent;
+    // TODO(b/170891664): Don't use a flag, set the BubbleOverflow object instead
+    private boolean mIsOverflow;
+
+    private BubbleController mController;
+    private BubbleStackView mStackView;
+    private BubblePositioner mPositioner;
+
+    /**
+     * Container for the ActivityView that has a solid, round-rect background that shows if the
+     * ActivityView hasn't loaded.
+     */
+    private final FrameLayout mExpandedViewContainer = new FrameLayout(getContext());
+
+    private final TaskView.Listener mTaskViewListener = new TaskView.Listener() {
+        private boolean mInitialized = false;
+        private boolean mDestroyed = false;
+
+        @Override
+        public void onInitialized() {
+            if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+                Log.d(TAG, "onActivityViewReady: destroyed=" + mDestroyed
+                        + " initialized=" + mInitialized
+                        + " bubble=" + getBubbleKey());
+            }
+
+            if (mDestroyed || mInitialized) {
+                return;
+            }
+            // Custom options so there is no activity transition animation
+            ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(),
+                    0 /* enterResId */, 0 /* exitResId */);
+
+            // TODO: I notice inconsistencies in lifecycle
+            // Post to keep the lifecycle normal
+            post(() -> {
+                if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+                    Log.d(TAG, "onActivityViewReady: calling startActivity, bubble="
+                            + getBubbleKey());
+                }
+                try {
+                    if (!mIsOverflow && mBubble.hasMetadataShortcutId()) {
+                        options.setApplyActivityFlagsForBubbles(true);
+                        mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
+                                options, null /* sourceBounds */);
+                    } else {
+                        Intent fillInIntent = new Intent();
+                        // Apply flags to make behaviour match documentLaunchMode=always.
+                        fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
+                        fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
+                        if (mBubble != null) {
+                            mBubble.setIntentActive();
+                        }
+                        mTaskView.startActivity(mPendingIntent, fillInIntent, options);
+                    }
+                } catch (RuntimeException e) {
+                    // If there's a runtime exception here then there's something
+                    // wrong with the intent, we can't really recover / try to populate
+                    // the bubble again so we'll just remove it.
+                    Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
+                            + ", " + e.getMessage() + "; removing bubble");
+                    mController.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
+                }
+            });
+            mInitialized = true;
+        }
+
+        @Override
+        public void onReleased() {
+            mDestroyed = true;
+        }
+
+        @Override
+        public void onTaskCreated(int taskId, ComponentName name) {
+            if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+                Log.d(TAG, "onTaskCreated: taskId=" + taskId
+                        + " bubble=" + getBubbleKey());
+            }
+            // The taskId is saved to use for removeTask, preventing appearance in recent tasks.
+            mTaskId = taskId;
+
+            // With the task org, the taskAppeared callback will only happen once the task has
+            // already drawn
+            setContentVisibility(true);
+        }
+
+        @Override
+        public void onTaskVisibilityChanged(int taskId, boolean visible) {
+            setContentVisibility(visible);
+        }
+
+        @Override
+        public void onTaskRemovalStarted(int taskId) {
+            if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+                Log.d(TAG, "onTaskRemovalStarted: taskId=" + taskId
+                        + " bubble=" + getBubbleKey());
+            }
+            if (mBubble != null) {
+                // Must post because this is called from a binder thread.
+                post(() -> mController.removeBubble(
+                        mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED));
+            }
+        }
+
+        @Override
+        public void onBackPressedOnTaskRoot(int taskId) {
+            if (mTaskId == taskId && mStackView.isExpanded()) {
+                mController.collapseStack();
+            }
+        }
+    };
+
+    public BubbleExpandedView(Context context) {
+        this(context, null);
+    }
+
+    public BubbleExpandedView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr) {
+        this(context, attrs, defStyleAttr, 0);
+    }
+
+    public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr,
+            int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+        updateDimensions();
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    @Override
+    protected void onFinishInflate() {
+        super.onFinishInflate();
+
+        Resources res = getResources();
+        mPointerView = findViewById(R.id.pointer_view);
+        mPointerWidth = res.getDimensionPixelSize(R.dimen.bubble_pointer_width);
+        mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height);
+
+        mTopPointer = new ShapeDrawable(TriangleShape.create(
+                mPointerWidth, mPointerHeight, true /* pointUp */));
+        mLeftPointer = new ShapeDrawable(TriangleShape.createHorizontal(
+                mPointerWidth, mPointerHeight, true /* pointLeft */));
+        mRightPointer = new ShapeDrawable(TriangleShape.createHorizontal(
+                mPointerWidth, mPointerHeight, false /* pointLeft */));
+
+        mCurrentPointer = mTopPointer;
+        mPointerView.setVisibility(INVISIBLE);
+
+        mSettingsIconHeight = getContext().getResources().getDimensionPixelSize(
+                R.dimen.bubble_manage_button_height);
+        mSettingsIcon = findViewById(R.id.settings_button);
+
+        // Set ActivityView's alpha value as zero, since there is no view content to be shown.
+        setContentVisibility(false);
+
+        mExpandedViewContainer.setOutlineProvider(new ViewOutlineProvider() {
+            @Override
+            public void getOutline(View view, Outline outline) {
+                outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
+            }
+        });
+        mExpandedViewContainer.setClipToOutline(true);
+        mExpandedViewContainer.setLayoutParams(
+                new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+        addView(mExpandedViewContainer);
+
+        // Expanded stack layout, top to bottom:
+        // Expanded view container
+        // ==> bubble row
+        // ==> expanded view
+        //   ==> activity view
+        //   ==> manage button
+        bringChildToFront(mSettingsIcon);
+
+        applyThemeAttrs();
+
+        mExpandedViewPadding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding);
+        setClipToPadding(false);
+        setOnTouchListener((view, motionEvent) -> {
+            if (mTaskView == null) {
+                return false;
+            }
+
+            final Rect avBounds = new Rect();
+            mTaskView.getBoundsOnScreen(avBounds);
+
+            // Consume and ignore events on the expanded view padding that are within the
+            // ActivityView's vertical bounds. These events are part of a back gesture, and so they
+            // should not collapse the stack (which all other touches on areas around the AV would
+            // do).
+            if (motionEvent.getRawY() >= avBounds.top
+                            && motionEvent.getRawY() <= avBounds.bottom
+                            && (motionEvent.getRawX() < avBounds.left
+                                || motionEvent.getRawX() > avBounds.right)) {
+                return true;
+            }
+
+            return false;
+        });
+
+        // BubbleStackView is forced LTR, but we want to respect the locale for expanded view layout
+        // so the Manage button appears on the right.
+        setLayoutDirection(LAYOUT_DIRECTION_LOCALE);
+    }
+
+    @Override
+    protected void onAttachedToWindow() {
+        super.onAttachedToWindow();
+        mTaskView.setExecutor(new HandlerExecutor(getHandler()));
+    }
+    /**
+     * Initialize {@link BubbleController} and {@link BubbleStackView} here, this method must need
+     * to be called after view inflate.
+     */
+    void initialize(BubbleController controller, BubbleStackView stackView) {
+        mController = controller;
+        mStackView = stackView;
+
+        mTaskView = new TaskView(mContext, mController.getTaskOrganizer());
+        mExpandedViewContainer.addView(mTaskView);
+        bringChildToFront(mTaskView);
+        mTaskView.setListener(mTaskViewListener);
+        mPositioner = mController.getPositioner();
+    }
+
+    void updateDimensions() {
+        Resources res = getResources();
+        mMinHeight = res.getDimensionPixelSize(R.dimen.bubble_expanded_default_height);
+        mOverflowHeight = res.getDimensionPixelSize(R.dimen.bubble_overflow_height);
+        mPointerMargin = res.getDimensionPixelSize(R.dimen.bubble_pointer_margin);
+    }
+
+    void applyThemeAttrs() {
+        final TypedArray ta = mContext.obtainStyledAttributes(new int[] {
+                android.R.attr.dialogCornerRadius,
+                android.R.attr.colorBackgroundFloating});
+        mCornerRadius = ta.getDimensionPixelSize(0, 0);
+        mExpandedViewContainer.setBackgroundColor(ta.getColor(1, Color.WHITE));
+        ta.recycle();
+
+        if (mTaskView != null && ScreenDecorationsUtils.supportsRoundedCornersOnWindows(
+                mContext.getResources())) {
+            mTaskView.setCornerRadius(mCornerRadius);
+        }
+        updatePointerView();
+    }
+
+    private void updatePointerView() {
+        final int mode =
+                getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
+        switch (mode) {
+            case Configuration.UI_MODE_NIGHT_NO:
+                mCurrentPointer.setTint(getResources().getColor(R.color.bubbles_light));
+                break;
+            case Configuration.UI_MODE_NIGHT_YES:
+                mCurrentPointer.setTint(getResources().getColor(R.color.bubbles_dark));
+                break;
+        }
+        LayoutParams lp = (LayoutParams) mPointerView.getLayoutParams();
+        if (mCurrentPointer == mLeftPointer || mCurrentPointer == mRightPointer) {
+            lp.width = mPointerHeight;
+            lp.height = mPointerWidth;
+        } else {
+            lp.width = mPointerWidth;
+            lp.height = mPointerHeight;
+        }
+        mPointerView.setLayoutParams(lp);
+        mPointerView.setBackground(mCurrentPointer);
+    }
+
+
+    private String getBubbleKey() {
+        return mBubble != null ? mBubble.getKey() : "null";
+    }
+
+    /**
+     * Sets whether the surface displaying app content should sit on top. This is useful for
+     * ordering surfaces during animations. When content is drawn on top of the app (e.g. bubble
+     * being dragged out, the manage menu) this is set to false, otherwise it should be true.
+     */
+    void setSurfaceZOrderedOnTop(boolean onTop) {
+        if (mTaskView == null) {
+            return;
+        }
+        mTaskView.setZOrderedOnTop(onTop, true /* allowDynamicChange */);
+    }
+
+    void setImeVisible(boolean visible) {
+        mImeVisible = visible;
+        if (!mImeVisible && mNeedsNewHeight) {
+            updateHeight();
+        }
+    }
+
+    /** Return a GraphicBuffer with the contents of the task view surface. */
+    @Nullable
+    SurfaceControl.ScreenshotHardwareBuffer snapshotActivitySurface() {
+        if (mTaskView == null) {
+            return null;
+        }
+        return SurfaceControl.captureLayers(
+                mTaskView.getSurfaceControl(),
+                new Rect(0, 0, mTaskView.getWidth(), mTaskView.getHeight()),
+                1 /* scale */);
+    }
+
+    int[] getTaskViewLocationOnScreen() {
+        if (mTaskView != null) {
+            return mTaskView.getLocationOnScreen();
+        } else {
+            return new int[]{0, 0};
+        }
+    }
+
+    // TODO: Could listener be passed when we pass StackView / can we avoid setting this like this
+    void setManageClickListener(OnClickListener manageClickListener) {
+        mSettingsIcon.setOnClickListener(manageClickListener);
+    }
+
+    /**
+     * Updates the obscured touchable region for the task surface. This calls onLocationChanged,
+     * which results in a call to {@link BubbleStackView#subtractObscuredTouchableRegion}. This is
+     * useful if a view has been added or removed from on top of the ActivityView, such as the
+     * manage menu.
+     */
+    void updateObscuredTouchableRegion() {
+        if (mTaskView != null) {
+            mTaskView.onLocationChanged();
+        }
+    }
+
+    @Override
+    protected void onDetachedFromWindow() {
+        super.onDetachedFromWindow();
+        mImeVisible = false;
+        mNeedsNewHeight = false;
+        if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+            Log.d(TAG, "onDetachedFromWindow: bubble=" + getBubbleKey());
+        }
+    }
+
+    /**
+     * Set visibility of contents in the expanded state.
+     *
+     * @param visibility {@code true} if the contents should be visible on the screen.
+     *
+     * Note that this contents visibility doesn't affect visibility at {@link android.view.View},
+     * and setting {@code false} actually means rendering the contents in transparent.
+     */
+    void setContentVisibility(boolean visibility) {
+        if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+            Log.d(TAG, "setContentVisibility: visibility=" + visibility
+                    + " bubble=" + getBubbleKey());
+        }
+        final float alpha = visibility ? 1f : 0f;
+
+        mPointerView.setAlpha(alpha);
+        if (mTaskView == null) {
+            return;
+        }
+        if (alpha != mTaskView.getAlpha()) {
+            mTaskView.setAlpha(alpha);
+        }
+    }
+
+    @Nullable
+    View getTaskView() {
+        return mTaskView;
+    }
+
+    int getTaskId() {
+        return mTaskId;
+    }
+
+    public void setOverflow(boolean overflow) {
+        mIsOverflow = overflow;
+
+        Intent target = new Intent(mContext, BubbleOverflowActivity.class);
+        Bundle extras = new Bundle();
+        extras.putBinder(EXTRA_BUBBLE_CONTROLLER, ObjectWrapper.wrap(mController));
+        target.putExtras(extras);
+        mPendingIntent = PendingIntent.getActivity(mContext, 0 /* requestCode */,
+                target, PendingIntent.FLAG_UPDATE_CURRENT);
+        mSettingsIcon.setVisibility(GONE);
+    }
+
+    /**
+     * Sets the bubble used to populate this view.
+     */
+    void update(Bubble bubble) {
+        if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+            Log.d(TAG, "update: bubble=" + bubble);
+        }
+        if (mStackView == null) {
+            Log.w(TAG, "Stack is null for bubble: " + bubble);
+            return;
+        }
+        boolean isNew = mBubble == null || didBackingContentChange(bubble);
+        if (isNew || bubble != null && bubble.getKey().equals(mBubble.getKey())) {
+            mBubble = bubble;
+            mSettingsIcon.setContentDescription(getResources().getString(
+                    R.string.bubbles_settings_button_description, bubble.getAppName()));
+            mSettingsIcon.setAccessibilityDelegate(
+                    new AccessibilityDelegate() {
+                        @Override
+                        public void onInitializeAccessibilityNodeInfo(View host,
+                                AccessibilityNodeInfo info) {
+                            super.onInitializeAccessibilityNodeInfo(host, info);
+                            // On focus, have TalkBack say
+                            // "Actions available. Use swipe up then right to view."
+                            // in addition to the default "double tap to activate".
+                            mStackView.setupLocalMenu(info);
+                        }
+                    });
+
+            if (isNew) {
+                mPendingIntent = mBubble.getBubbleIntent();
+                if (mPendingIntent != null || mBubble.hasMetadataShortcutId()) {
+                    setContentVisibility(false);
+                    mTaskView.setVisibility(VISIBLE);
+                }
+            }
+            applyThemeAttrs();
+        } else {
+            Log.w(TAG, "Trying to update entry with different key, new bubble: "
+                    + bubble.getKey() + " old bubble: " + bubble.getKey());
+        }
+    }
+
+    /**
+     * Bubbles are backed by a pending intent or a shortcut, once the activity is
+     * started we never change it / restart it on notification updates -- unless the bubbles'
+     * backing data switches.
+     *
+     * This indicates if the new bubble is backed by a different data source than what was
+     * previously shown here (e.g. previously a pending intent & now a shortcut).
+     *
+     * @param newBubble the bubble this view is being updated with.
+     * @return true if the backing content has changed.
+     */
+    private boolean didBackingContentChange(Bubble newBubble) {
+        boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
+        boolean newIsIntentBased = newBubble.getBubbleIntent() != null;
+        return prevWasIntentBased != newIsIntentBased;
+    }
+
+    void updateHeight() {
+        if (mExpandedViewContainerLocation == null) {
+            return;
+        }
+
+        if (mBubble != null || mIsOverflow) {
+            float desiredHeight = mIsOverflow
+                    ? mOverflowHeight
+                    : mBubble.getDesiredHeight(mContext);
+            desiredHeight = Math.max(desiredHeight, mMinHeight);
+            float height = Math.min(desiredHeight, getMaxExpandedHeight());
+            height = Math.max(height, mMinHeight);
+            FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) mTaskView.getLayoutParams();
+            mNeedsNewHeight = lp.height != height;
+            if (!mImeVisible) {
+                // If the ime is visible... don't adjust the height because that will cause
+                // a configuration change and the ime will be lost.
+                lp.height = (int) height;
+                mTaskView.setLayoutParams(lp);
+                mNeedsNewHeight = false;
+            }
+            if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+                Log.d(TAG, "updateHeight: bubble=" + getBubbleKey()
+                        + " height=" + height
+                        + " mNeedsNewHeight=" + mNeedsNewHeight);
+            }
+        }
+    }
+
+    private int getMaxExpandedHeight() {
+        int expandedContainerY = mExpandedViewContainerLocation != null
+                // Remove top insets back here because availableRect.height would account for that
+                ? mExpandedViewContainerLocation[1] - mPositioner.getInsets().top
+                : 0;
+        return mPositioner.getAvailableRect().height()
+                - expandedContainerY
+                - getPaddingTop()
+                - getPaddingBottom()
+                - mSettingsIconHeight
+                - mPointerHeight
+                - mPointerMargin;
+    }
+
+    /**
+     * Update appearance of the expanded view being displayed.
+     *
+     * @param containerLocationOnScreen The location on-screen of the container the expanded view is
+     *                                  added to. This allows us to calculate max height without
+     *                                  waiting for layout.
+     */
+    public void updateView(int[] containerLocationOnScreen) {
+        if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+            Log.d(TAG, "updateView: bubble="
+                    + getBubbleKey());
+        }
+        mExpandedViewContainerLocation = containerLocationOnScreen;
+        if (mTaskView != null
+                && mTaskView.getVisibility() == VISIBLE
+                && mTaskView.isAttachedToWindow()) {
+            updateHeight();
+            mTaskView.onLocationChanged();
+        }
+    }
+
+    /**
+     * Set the position that the tip of the triangle should point to.
+     */
+    public void setPointerPosition(float x, float y, boolean isLandscape, boolean onLeft) {
+        // Pointer gets drawn in the padding
+        int paddingLeft = (isLandscape && onLeft) ? mPointerHeight : 0;
+        int paddingRight = (isLandscape && !onLeft) ? mPointerHeight : 0;
+        int paddingTop = isLandscape ? 0 : mExpandedViewPadding;
+        setPadding(paddingLeft, paddingTop, paddingRight, 0);
+
+        if (isLandscape) {
+            // TODO: why setY vs setTranslationY ? linearlayout?
+            mPointerView.setY(y - (mPointerWidth / 2f));
+            mPointerView.setTranslationX(onLeft ? -mPointerHeight : x - mExpandedViewPadding);
+        } else {
+            mPointerView.setTranslationY(0f);
+            mPointerView.setTranslationX(x - mExpandedViewPadding - (mPointerWidth / 2f));
+        }
+        mCurrentPointer = isLandscape ? onLeft ? mLeftPointer : mRightPointer : mTopPointer;
+        updatePointerView();
+        mPointerView.setVisibility(VISIBLE);
+    }
+
+    /**
+     * Position of the manage button displayed in the expanded view. Used for placing user
+     * education about the manage button.
+     */
+    public void getManageButtonBoundsOnScreen(Rect rect) {
+        mSettingsIcon.getBoundsOnScreen(rect);
+    }
+
+    /**
+     * Cleans up anything related to the task and TaskView.
+     */
+    public void cleanUpExpandedState() {
+        if (DEBUG_BUBBLE_EXPANDED_VIEW) {
+            Log.d(TAG, "cleanUpExpandedState: bubble=" + getBubbleKey() + " task=" + mTaskId);
+        }
+        if (mTaskView != null) {
+            mTaskView.release();
+        }
+        if (mTaskView != null) {
+            removeView(mTaskView);
+            mTaskView = null;
+        }
+    }
+
+    /**
+     * Description of current expanded view state.
+     */
+    public void dump(
+            @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
+        pw.print("BubbleExpandedView");
+        pw.print("  taskId:               "); pw.println(mTaskId);
+        pw.print("  stackView:            "); pw.println(mStackView);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java
new file mode 100644
index 0000000..460e0e7
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleFlyoutView.java
@@ -0,0 +1,527 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.graphics.Paint.ANTI_ALIAS_FLAG;
+import static android.graphics.Paint.FILTER_BITMAP_FLAG;
+
+import static com.android.wm.shell.animation.Interpolators.ALPHA_IN;
+import static com.android.wm.shell.animation.Interpolators.ALPHA_OUT;
+
+import android.animation.ArgbEvaluator;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Outline;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.text.TextUtils;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.common.TriangleShape;
+
+/**
+ * Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually
+ * transform into the 'new' dot, which is used during flyout dismiss animations/gestures.
+ */
+public class BubbleFlyoutView extends FrameLayout {
+    /** Max width of the flyout, in terms of percent of the screen width. */
+    private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f;
+
+    /** Translation Y of fade animation. */
+    private static final float FLYOUT_FADE_Y = 40f;
+
+    private static final long FLYOUT_FADE_OUT_DURATION = 150L;
+    private static final long FLYOUT_FADE_IN_DURATION = 250L;
+
+    private final int mFlyoutPadding;
+    private final int mFlyoutSpaceFromBubble;
+    private final int mPointerSize;
+    private final int mBubbleSize;
+    private final int mBubbleBitmapSize;
+    private final float mBubbleIconTopPadding;
+
+    private final int mFlyoutElevation;
+    private final int mBubbleElevation;
+    private final int mFloatingBackgroundColor;
+    private final float mCornerRadius;
+
+    private final ViewGroup mFlyoutTextContainer;
+    private final ImageView mSenderAvatar;
+    private final TextView mSenderText;
+    private final TextView mMessageText;
+
+    /** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */
+    private final float mNewDotRadius;
+    private final float mNewDotSize;
+    private final float mOriginalDotSize;
+
+    /**
+     * The paint used to draw the background, whose color changes as the flyout transitions to the
+     * tinted 'new' dot.
+     */
+    private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG);
+    private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator();
+
+    /**
+     * Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble
+     * stack (a chat-bubble effect).
+     */
+    private final ShapeDrawable mLeftTriangleShape;
+    private final ShapeDrawable mRightTriangleShape;
+
+    /** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */
+    private boolean mArrowPointingLeft = true;
+
+    /** Color of the 'new' dot that the flyout will transform into. */
+    private int mDotColor;
+
+    /** The outline of the triangle, used for elevation shadows. */
+    private final Outline mTriangleOutline = new Outline();
+
+    /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */
+    private final RectF mBgRect = new RectF();
+
+    /** The y position of the flyout, relative to the top of the screen. */
+    private float mFlyoutY = 0f;
+
+    /**
+     * Percent progress in the transition from flyout to 'new' dot. These two values are the inverse
+     * of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code
+     * much more readable.
+     */
+    private float mPercentTransitionedToDot = 1f;
+    private float mPercentStillFlyout = 0f;
+
+    /**
+     * The difference in values between the flyout and the dot. These differences are gradually
+     * added over the course of the animation to transform the flyout into the 'new' dot.
+     */
+    private float mFlyoutToDotWidthDelta = 0f;
+    private float mFlyoutToDotHeightDelta = 0f;
+
+    /** The translation values when the flyout is completely transitioned into the dot. */
+    private float mTranslationXWhenDot = 0f;
+    private float mTranslationYWhenDot = 0f;
+
+    /**
+     * The current translation values applied to the flyout background as it transitions into the
+     * 'new' dot.
+     */
+    private float mBgTranslationX;
+    private float mBgTranslationY;
+
+    private float[] mDotCenter;
+
+    /** The flyout's X translation when at rest (not animating or dragging). */
+    private float mRestingTranslationX = 0f;
+
+    /** The badge sizes are defined as percentages of the app icon size. Same value as Launcher3. */
+    private static final float SIZE_PERCENTAGE = 0.228f;
+
+    private static final float DOT_SCALE = 1f;
+
+    /** Callback to run when the flyout is hidden. */
+    @Nullable private Runnable mOnHide;
+
+    public BubbleFlyoutView(Context context) {
+        super(context);
+        LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
+
+        mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
+        mSenderText = findViewById(R.id.bubble_flyout_name);
+        mSenderAvatar = findViewById(R.id.bubble_flyout_avatar);
+        mMessageText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
+
+        final Resources res = getResources();
+        mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
+        mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble);
+        mPointerSize = res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size);
+
+        mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mBubbleBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_bitmap_size);
+        mBubbleIconTopPadding  = (mBubbleSize - mBubbleBitmapSize) / 2f;
+
+        mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+        mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation);
+
+        mOriginalDotSize = SIZE_PERCENTAGE * mBubbleBitmapSize;
+        mNewDotRadius = (DOT_SCALE * mOriginalDotSize) / 2f;
+        mNewDotSize = mNewDotRadius * 2f;
+
+        final TypedArray ta = mContext.obtainStyledAttributes(
+                new int[] {
+                        android.R.attr.colorBackgroundFloating,
+                        android.R.attr.dialogCornerRadius});
+        mFloatingBackgroundColor = ta.getColor(0, Color.WHITE);
+        mCornerRadius = ta.getDimensionPixelSize(1, 0);
+        ta.recycle();
+
+        // Add padding for the pointer on either side, onDraw will draw it in this space.
+        setPadding(mPointerSize, 0, mPointerSize, 0);
+        setWillNotDraw(false);
+        setClipChildren(false);
+        setTranslationZ(mFlyoutElevation);
+        setOutlineProvider(new ViewOutlineProvider() {
+            @Override
+            public void getOutline(View view, Outline outline) {
+                BubbleFlyoutView.this.getOutline(outline);
+            }
+        });
+
+        // Use locale direction so the text is aligned correctly.
+        setLayoutDirection(LAYOUT_DIRECTION_LOCALE);
+
+        mBgPaint.setColor(mFloatingBackgroundColor);
+
+        mLeftTriangleShape =
+                new ShapeDrawable(TriangleShape.createHorizontal(
+                        mPointerSize, mPointerSize, true /* isPointingLeft */));
+        mLeftTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
+        mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+
+        mRightTriangleShape =
+                new ShapeDrawable(TriangleShape.createHorizontal(
+                        mPointerSize, mPointerSize, false /* isPointingLeft */));
+        mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
+        mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        renderBackground(canvas);
+        invalidateOutline();
+        super.onDraw(canvas);
+    }
+
+    void updateFontSize(float fontScale) {
+        final float fontSize = mContext.getResources()
+                .getDimensionPixelSize(com.android.internal.R.dimen.text_size_body_2_material);
+        final float newFontSize = fontSize * fontScale;
+        mMessageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, newFontSize);
+        mSenderText.setTextSize(TypedValue.COMPLEX_UNIT_PX, newFontSize);
+    }
+
+    /*
+     * Fade animation for consecutive flyouts.
+     */
+    void animateUpdate(Bubble.FlyoutMessage flyoutMessage, float parentWidth, float stackY) {
+        final Runnable afterFadeOut = () -> {
+            updateFlyoutMessage(flyoutMessage, parentWidth);
+            // Wait for TextViews to layout with updated height.
+            post(() -> {
+                mFlyoutY = stackY + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
+                fade(true /* in */, () -> {} /* after */);
+            } /* after */ );
+        };
+        fade(false /* in */, afterFadeOut);
+    }
+
+    /*
+     * Fade-out above or fade-in from below.
+     */
+    private void fade(boolean in, Runnable afterFade) {
+        setAlpha(in ? 0f : 1f);
+        setTranslationY(in ? mFlyoutY + FLYOUT_FADE_Y : mFlyoutY);
+        animate()
+                .alpha(in ? 1f : 0f)
+                .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION)
+                .setInterpolator(in ? ALPHA_IN : ALPHA_OUT);
+        animate()
+                .translationY(in ? mFlyoutY : mFlyoutY - FLYOUT_FADE_Y)
+                .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION)
+                .setInterpolator(in ? ALPHA_IN : ALPHA_OUT)
+                .withEndAction(afterFade);
+    }
+
+    private void updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage, float parentWidth) {
+        final Drawable senderAvatar = flyoutMessage.senderAvatar;
+        if (senderAvatar != null && flyoutMessage.isGroupChat) {
+            mSenderAvatar.setVisibility(VISIBLE);
+            mSenderAvatar.setImageDrawable(senderAvatar);
+        } else {
+            mSenderAvatar.setVisibility(GONE);
+            mSenderAvatar.setTranslationX(0);
+            mMessageText.setTranslationX(0);
+            mSenderText.setTranslationX(0);
+        }
+
+        final int maxTextViewWidth =
+                (int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2;
+
+        // Name visibility
+        if (!TextUtils.isEmpty(flyoutMessage.senderName)) {
+            mSenderText.setMaxWidth(maxTextViewWidth);
+            mSenderText.setText(flyoutMessage.senderName);
+            mSenderText.setVisibility(VISIBLE);
+        } else {
+            mSenderText.setVisibility(GONE);
+        }
+
+        // Set the flyout TextView's max width in terms of percent, and then subtract out the
+        // padding so that the entire flyout view will be the desired width (rather than the
+        // TextView being the desired width + extra padding).
+        mMessageText.setMaxWidth(maxTextViewWidth);
+        mMessageText.setText(flyoutMessage.message);
+    }
+
+    /** Configures the flyout, collapsed into dot form. */
+    void setupFlyoutStartingAsDot(
+            Bubble.FlyoutMessage flyoutMessage,
+            PointF stackPos,
+            float parentWidth,
+            boolean arrowPointingLeft,
+            int dotColor,
+            @Nullable Runnable onLayoutComplete,
+            @Nullable Runnable onHide,
+            float[] dotCenter,
+            boolean hideDot)  {
+
+        updateFlyoutMessage(flyoutMessage, parentWidth);
+
+        mArrowPointingLeft = arrowPointingLeft;
+        mDotColor = dotColor;
+        mOnHide = onHide;
+        mDotCenter = dotCenter;
+
+        setCollapsePercent(1f);
+
+        // Wait for TextViews to layout with updated height.
+        post(() -> {
+            // Flyout is vertically centered with respect to the bubble.
+            mFlyoutY =
+                    stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
+            setTranslationY(mFlyoutY);
+
+            // Calculate the translation required to position the flyout next to the bubble stack,
+            // with the desired padding.
+            mRestingTranslationX = mArrowPointingLeft
+                    ? stackPos.x + mBubbleSize + mFlyoutSpaceFromBubble
+                    : stackPos.x - getWidth() - mFlyoutSpaceFromBubble;
+
+            // Calculate the difference in size between the flyout and the 'dot' so that we can
+            // transform into the dot later.
+            final float newDotSize = hideDot ? 0f : mNewDotSize;
+            mFlyoutToDotWidthDelta = getWidth() - newDotSize;
+            mFlyoutToDotHeightDelta = getHeight() - newDotSize;
+
+            // Calculate the translation values needed to be in the correct 'new dot' position.
+            final float adjustmentForScaleAway = hideDot ? 0f : (mOriginalDotSize / 2f);
+            final float dotPositionX = stackPos.x + mDotCenter[0] - adjustmentForScaleAway;
+            final float dotPositionY = stackPos.y + mDotCenter[1] - adjustmentForScaleAway;
+
+            final float distanceFromFlyoutLeftToDotCenterX = mRestingTranslationX - dotPositionX;
+            final float distanceFromLayoutTopToDotCenterY = mFlyoutY - dotPositionY;
+
+            mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX;
+            mTranslationYWhenDot = -distanceFromLayoutTopToDotCenterY;
+            if (onLayoutComplete != null) {
+                onLayoutComplete.run();
+            }
+        });
+    }
+
+    /**
+     * Hides the flyout and runs the optional callback passed into setupFlyoutStartingAsDot.
+     * The flyout has been animated into the 'new' dot by the time we call this, so no animations
+     * are needed.
+     */
+    void hideFlyout() {
+        if (mOnHide != null) {
+            mOnHide.run();
+            mOnHide = null;
+        }
+
+        setVisibility(GONE);
+    }
+
+    /** Sets the percentage that the flyout should be collapsed into dot form. */
+    void setCollapsePercent(float percentCollapsed) {
+        // This is unlikely, but can happen in a race condition where the flyout view hasn't been
+        // laid out and returns 0 for getWidth(). We check for this condition at the sites where
+        // this method is called, but better safe than sorry.
+        if (Float.isNaN(percentCollapsed)) {
+            return;
+        }
+
+        mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f));
+        mPercentStillFlyout = (1f - mPercentTransitionedToDot);
+
+        // Move and fade out the text.
+        final float translationX = mPercentTransitionedToDot
+                * (mArrowPointingLeft ? -getWidth() : getWidth());
+        final float alpha = clampPercentage(
+                (mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS))
+                        / BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS);
+
+        mMessageText.setTranslationX(translationX);
+        mMessageText.setAlpha(alpha);
+
+        mSenderText.setTranslationX(translationX);
+        mSenderText.setAlpha(alpha);
+
+        mSenderAvatar.setTranslationX(translationX);
+        mSenderAvatar.setAlpha(alpha);
+
+        // Reduce the elevation towards that of the topmost bubble.
+        setTranslationZ(
+                mFlyoutElevation
+                        - (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot);
+        invalidate();
+    }
+
+    /** Return the flyout's resting X translation (translation when not dragging or animating). */
+    float getRestingTranslationX() {
+        return mRestingTranslationX;
+    }
+
+    /** Clamps a float to between 0 and 1. */
+    private float clampPercentage(float percent) {
+        return Math.min(1f, Math.max(0f, percent));
+    }
+
+    /**
+     * Renders the background, which is either the rounded 'chat bubble' flyout, or some state
+     * between that and the 'new' dot over the bubbles.
+     */
+    private void renderBackground(Canvas canvas) {
+        // Calculate the width, height, and corner radius of the flyout given the current collapsed
+        // percentage.
+        final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot);
+        final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot);
+        final float interpolatedRadius = getInterpolatedRadius();
+
+        // Translate the flyout background towards the collapsed 'dot' state.
+        mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot;
+        mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot;
+
+        // Set the bounds of the rounded rectangle that serves as either the flyout background or
+        // the collapsed 'dot'. These bounds will also be used to provide the outline for elevation
+        // shadows. In the expanded flyout state, the left and right bounds leave space for the
+        // pointer triangle - as the flyout collapses, this space is reduced since the triangle
+        // retracts into the flyout.
+        mBgRect.set(
+                mPointerSize * mPercentStillFlyout /* left */,
+                0 /* top */,
+                width - mPointerSize * mPercentStillFlyout /* right */,
+                height /* bottom */);
+
+        mBgPaint.setColor(
+                (int) mArgbEvaluator.evaluate(
+                        mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor));
+
+        canvas.save();
+        canvas.translate(mBgTranslationX, mBgTranslationY);
+        renderPointerTriangle(canvas, width, height);
+        canvas.drawRoundRect(mBgRect, interpolatedRadius, interpolatedRadius, mBgPaint);
+        canvas.restore();
+    }
+
+    /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */
+    private void renderPointerTriangle(
+            Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) {
+        canvas.save();
+
+        // Translation to apply for the 'retraction' effect as the flyout collapses.
+        final float retractionTranslationX =
+                (mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f);
+
+        // Place the arrow either at the left side, or the far right, depending on whether the
+        // flyout is on the left or right side.
+        final float arrowTranslationX =
+                mArrowPointingLeft
+                        ? retractionTranslationX
+                        : currentFlyoutWidth - mPointerSize + retractionTranslationX;
+
+        // Vertically center the arrow at all times.
+        final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f;
+
+        // Draw the appropriate direction of arrow.
+        final ShapeDrawable relevantTriangle =
+                mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape;
+        canvas.translate(arrowTranslationX, arrowTranslationY);
+        relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout));
+        relevantTriangle.draw(canvas);
+
+        // Save the triangle's outline for use in the outline provider, offsetting it to reflect its
+        // current position.
+        relevantTriangle.getOutline(mTriangleOutline);
+        mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY);
+
+        canvas.restore();
+    }
+
+    /** Builds an outline that includes the transformed flyout background and triangle. */
+    private void getOutline(Outline outline) {
+        if (!mTriangleOutline.isEmpty()) {
+            // Draw the rect into the outline as a path so we can merge the triangle path into it.
+            final Path rectPath = new Path();
+            final float interpolatedRadius = getInterpolatedRadius();
+            rectPath.addRoundRect(mBgRect, interpolatedRadius,
+                    interpolatedRadius, Path.Direction.CW);
+            outline.setPath(rectPath);
+
+            // Get rid of the triangle path once it has disappeared behind the flyout.
+            if (mPercentStillFlyout > 0.5f) {
+                outline.mPath.addPath(mTriangleOutline.mPath);
+            }
+
+            // Translate the outline to match the background's position.
+            final Matrix outlineMatrix = new Matrix();
+            outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY);
+
+            // At the very end, retract the outline into the bubble so the shadow will be pulled
+            // into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by
+            // animating translationZ to zero since then it'll go under the bubbles, which have
+            // elevation.
+            if (mPercentTransitionedToDot > 0.98f) {
+                final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f;
+                final float percentShadowVisible = 1f - percentBetween99and100;
+
+                // Keep it centered.
+                outlineMatrix.postTranslate(
+                        mNewDotRadius * percentBetween99and100,
+                        mNewDotRadius * percentBetween99and100);
+                outlineMatrix.preScale(percentShadowVisible, percentShadowVisible);
+            }
+
+            outline.mPath.transform(outlineMatrix);
+        }
+    }
+
+    private float getInterpolatedRadius() {
+        return mNewDotRadius * mPercentTransitionedToDot
+                + mCornerRadius * (1 - mPercentTransitionedToDot);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleIconFactory.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleIconFactory.java
new file mode 100644
index 0000000..2d9da21
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleIconFactory.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.LauncherApps;
+import android.content.pm.ShortcutInfo;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.drawable.AdaptiveIconDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+
+import com.android.launcher3.icons.BaseIconFactory;
+import com.android.launcher3.icons.BitmapInfo;
+import com.android.launcher3.icons.ShadowGenerator;
+import com.android.wm.shell.R;
+
+/**
+ * Factory for creating normalized bubble icons.
+ * We are not using Launcher's IconFactory because bubbles only runs on the UI thread,
+ * so there is no need to manage a pool across multiple threads.
+ */
+public class BubbleIconFactory extends BaseIconFactory {
+
+    private int mBadgeSize;
+
+    protected BubbleIconFactory(Context context) {
+        super(context, context.getResources().getConfiguration().densityDpi,
+                context.getResources().getDimensionPixelSize(R.dimen.individual_bubble_size));
+        mBadgeSize = mContext.getResources().getDimensionPixelSize(
+                com.android.launcher3.icons.R.dimen.profile_badge_size);
+    }
+
+    /**
+     * Returns the drawable that the developer has provided to display in the bubble.
+     */
+    Drawable getBubbleDrawable(@NonNull final Context context,
+            @Nullable final ShortcutInfo shortcutInfo, @Nullable final Icon ic) {
+        if (shortcutInfo != null) {
+            LauncherApps launcherApps =
+                    (LauncherApps) context.getSystemService(Context.LAUNCHER_APPS_SERVICE);
+            int density = context.getResources().getConfiguration().densityDpi;
+            return launcherApps.getShortcutIconDrawable(shortcutInfo, density);
+        } else {
+            if (ic != null) {
+                if (ic.getType() == Icon.TYPE_URI
+                        || ic.getType() == Icon.TYPE_URI_ADAPTIVE_BITMAP) {
+                    context.grantUriPermission(context.getPackageName(),
+                            ic.getUri(),
+                            Intent.FLAG_GRANT_READ_URI_PERMISSION);
+                }
+                return ic.loadDrawable(context);
+            }
+            return null;
+        }
+    }
+
+    /**
+     * Returns a {@link BitmapInfo} for the app-badge that is shown on top of each bubble. This
+     * will include the workprofile indicator on the badge if appropriate.
+     */
+    BitmapInfo getBadgeBitmap(Drawable userBadgedAppIcon, boolean isImportantConversation) {
+        ShadowGenerator shadowGenerator = new ShadowGenerator(mBadgeSize);
+        Bitmap userBadgedBitmap = createIconBitmap(userBadgedAppIcon, 1f, mBadgeSize);
+
+        if (userBadgedAppIcon instanceof AdaptiveIconDrawable) {
+            userBadgedBitmap = Bitmap.createScaledBitmap(
+                    getCircleBitmap((AdaptiveIconDrawable) userBadgedAppIcon, /* size */
+                            userBadgedAppIcon.getIntrinsicWidth()),
+                    mBadgeSize, mBadgeSize, /* filter */ true);
+        }
+
+        if (isImportantConversation) {
+            final float ringStrokeWidth = mContext.getResources().getDimensionPixelSize(
+                    com.android.internal.R.dimen.importance_ring_stroke_width);
+            final int importantConversationColor = mContext.getResources().getColor(
+                    com.android.settingslib.R.color.important_conversation, null);
+            Bitmap badgeAndRing = Bitmap.createBitmap(userBadgedBitmap.getWidth(),
+                    userBadgedBitmap.getHeight(), userBadgedBitmap.getConfig());
+            Canvas c = new Canvas(badgeAndRing);
+
+            final int bitmapTop = (int) ringStrokeWidth;
+            final int bitmapLeft = (int) ringStrokeWidth;
+            final int bitmapWidth = c.getWidth() - 2 * (int) ringStrokeWidth;
+            final int bitmapHeight = c.getHeight() - 2 * (int) ringStrokeWidth;
+
+            Bitmap scaledBitmap = Bitmap.createScaledBitmap(userBadgedBitmap, bitmapWidth,
+                    bitmapHeight, /* filter */ true);
+            c.drawBitmap(scaledBitmap, bitmapTop, bitmapLeft, /* paint */null);
+
+            Paint ringPaint = new Paint();
+            ringPaint.setStyle(Paint.Style.STROKE);
+            ringPaint.setColor(importantConversationColor);
+            ringPaint.setAntiAlias(true);
+            ringPaint.setStrokeWidth(ringStrokeWidth);
+            c.drawCircle(c.getWidth() / 2, c.getHeight() / 2, c.getWidth() / 2 - ringStrokeWidth,
+                    ringPaint);
+
+            shadowGenerator.recreateIcon(Bitmap.createBitmap(badgeAndRing), c);
+            return createIconBitmap(badgeAndRing);
+        } else {
+            Canvas c = new Canvas();
+            c.setBitmap(userBadgedBitmap);
+            shadowGenerator.recreateIcon(Bitmap.createBitmap(userBadgedBitmap), c);
+            return createIconBitmap(userBadgedBitmap);
+        }
+    }
+
+    public Bitmap getCircleBitmap(AdaptiveIconDrawable icon, int size) {
+        Drawable foreground = icon.getForeground();
+        Drawable background = icon.getBackground();
+        Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+        Canvas canvas = new Canvas();
+        canvas.setBitmap(bitmap);
+
+        // Clip canvas to circle.
+        Path circlePath = new Path();
+        circlePath.addCircle(/* x */ size / 2f,
+                /* y */ size / 2f,
+                /* radius */ size / 2f,
+                Path.Direction.CW);
+        canvas.clipPath(circlePath);
+
+        // Draw background.
+        background.setBounds(0, 0, size, size);
+        background.draw(canvas);
+
+        // Draw foreground. The foreground and background drawables are derived from adaptive icons
+        // Some icon shapes fill more space than others, so adaptive icons are normalized to about
+        // the same size. This size is smaller than the original bounds, so we estimate
+        // the difference in this offset.
+        int offset = size / 5;
+        foreground.setBounds(-offset, -offset, size + offset, size + offset);
+        foreground.draw(canvas);
+
+        canvas.setBitmap(null);
+        return bitmap;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleLogger.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleLogger.java
new file mode 100644
index 0000000..3361c4c
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleLogger.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.logging.UiEvent;
+import com.android.internal.logging.UiEventLogger;
+import com.android.internal.util.FrameworkStatsLog;
+
+/**
+ * Implementation of UiEventLogger for logging bubble UI events.
+ *
+ * See UiEventReported atom in atoms.proto for more context.
+ */
+public class BubbleLogger {
+
+    private final UiEventLogger mUiEventLogger;
+
+    /**
+     * Bubble UI event.
+     */
+    @VisibleForTesting
+    public enum Event implements UiEventLogger.UiEventEnum {
+
+        @UiEvent(doc = "User dismissed the bubble via gesture, add bubble to overflow.")
+        BUBBLE_OVERFLOW_ADD_USER_GESTURE(483),
+
+        @UiEvent(doc = "No more space in top row, add bubble to overflow.")
+        BUBBLE_OVERFLOW_ADD_AGED(484),
+
+        @UiEvent(doc = "No more space in overflow, remove bubble from overflow")
+        BUBBLE_OVERFLOW_REMOVE_MAX_REACHED(485),
+
+        @UiEvent(doc = "Notification canceled, remove bubble from overflow.")
+        BUBBLE_OVERFLOW_REMOVE_CANCEL(486),
+
+        @UiEvent(doc = "Notification group canceled, remove bubble for child notif from overflow.")
+        BUBBLE_OVERFLOW_REMOVE_GROUP_CANCEL(487),
+
+        @UiEvent(doc = "Notification no longer bubble, remove bubble from overflow.")
+        BUBBLE_OVERFLOW_REMOVE_NO_LONGER_BUBBLE(488),
+
+        @UiEvent(doc = "User tapped overflow bubble. Promote bubble back to top row.")
+        BUBBLE_OVERFLOW_REMOVE_BACK_TO_STACK(489),
+
+        @UiEvent(doc = "User blocked notification from bubbling, remove bubble from overflow.")
+        BUBBLE_OVERFLOW_REMOVE_BLOCKED(490),
+
+        @UiEvent(doc = "User selected the overflow.")
+        BUBBLE_OVERFLOW_SELECTED(600);
+
+        private final int mId;
+
+        Event(int id) {
+            mId = id;
+        }
+
+        @Override
+        public int getId() {
+            return mId;
+        }
+    }
+
+    public BubbleLogger(UiEventLogger uiEventLogger) {
+        mUiEventLogger = uiEventLogger;
+    }
+
+    /**
+     * @param b Bubble involved in this UI event
+     * @param e UI event
+     */
+    public void log(Bubble b, UiEventLogger.UiEventEnum e) {
+        mUiEventLogger.logWithInstanceId(e, b.getAppUid(), b.getPackageName(), b.getInstanceId());
+    }
+
+    /**
+     * @param b Bubble removed from overflow
+     * @param r Reason that bubble was removed
+     */
+    public void logOverflowRemove(Bubble b, @Bubbles.DismissReason int r) {
+        if (r == Bubbles.DISMISS_NOTIF_CANCEL) {
+            log(b, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_CANCEL);
+        } else if (r == Bubbles.DISMISS_GROUP_CANCELLED) {
+            log(b, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_GROUP_CANCEL);
+        } else if (r == Bubbles.DISMISS_NO_LONGER_BUBBLE) {
+            log(b, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_NO_LONGER_BUBBLE);
+        } else if (r == Bubbles.DISMISS_BLOCKED) {
+            log(b, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_BLOCKED);
+        }
+    }
+
+    /**
+     * @param b Bubble added to overflow
+     * @param r Reason that bubble was added to overflow
+     */
+    public void logOverflowAdd(Bubble b, @Bubbles.DismissReason int r) {
+        if (r == Bubbles.DISMISS_AGED) {
+            log(b, Event.BUBBLE_OVERFLOW_ADD_AGED);
+        } else if (r == Bubbles.DISMISS_USER_GESTURE) {
+            log(b, Event.BUBBLE_OVERFLOW_ADD_USER_GESTURE);
+        }
+    }
+
+    void logStackUiChanged(String packageName, int action, int bubbleCount, float normalX,
+            float normalY) {
+        FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_UI_CHANGED,
+                packageName,
+                null /* notification channel */,
+                0 /* notification ID */,
+                0 /* bubble position */,
+                bubbleCount,
+                action,
+                normalX,
+                normalY,
+                false /* unread bubble */,
+                false /* on-going bubble */,
+                false /* isAppForeground (unused) */);
+    }
+
+    void logShowOverflow(String packageName, int currentUserId) {
+        mUiEventLogger.log(BubbleLogger.Event.BUBBLE_OVERFLOW_SELECTED, currentUserId,
+                packageName);
+    }
+
+    void logBubbleUiChanged(Bubble bubble, String packageName, int action, int bubbleCount,
+            float normalX, float normalY, int index) {
+        FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_UI_CHANGED,
+                packageName,
+                bubble.getChannelId() /* notification channel */,
+                bubble.getNotificationId() /* notification ID */,
+                index,
+                bubbleCount,
+                action,
+                normalX,
+                normalY,
+                bubble.showInShade() /* isUnread */,
+                false /* isOngoing (unused) */,
+                false /* isAppForeground (unused) */);
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt
new file mode 100644
index 0000000..686d2d4
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflow.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.app.ActivityTaskManager.INVALID_TASK_ID
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.Matrix
+import android.graphics.Path
+import android.graphics.drawable.AdaptiveIconDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.InsetDrawable
+import android.util.PathParser
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import com.android.wm.shell.R
+
+class BubbleOverflow(
+    private val context: Context,
+    private val controller: BubbleController,
+    private val stack: BubbleStackView
+) : BubbleViewProvider {
+
+    private lateinit var bitmap: Bitmap
+    private lateinit var dotPath: Path
+
+    private var bitmapSize = 0
+    private var iconBitmapSize = 0
+    private var dotColor = 0
+    private var showDot = false
+
+    private val inflater: LayoutInflater = LayoutInflater.from(context)
+    private val expandedView: BubbleExpandedView = inflater
+        .inflate(R.layout.bubble_expanded_view, null /* root */, false /* attachToRoot */)
+            as BubbleExpandedView
+    private val overflowBtn: BadgedImageView = inflater
+        .inflate(R.layout.bubble_overflow_button, null /* root */, false /* attachToRoot */)
+            as BadgedImageView
+    init {
+        updateResources()
+        with(expandedView) {
+            initialize(controller, stack)
+            setOverflow(true)
+            applyThemeAttrs()
+        }
+        with(overflowBtn) {
+            setContentDescription(context.resources.getString(
+                R.string.bubble_overflow_button_content_description))
+            updateBtnTheme()
+        }
+    }
+
+    fun update() {
+        updateResources()
+        expandedView.applyThemeAttrs()
+        // Apply inset and new style to fresh icon drawable.
+        overflowBtn.setImageResource(R.drawable.bubble_ic_overflow_button)
+        updateBtnTheme()
+    }
+
+    fun updateResources() {
+        bitmapSize = context.resources.getDimensionPixelSize(R.dimen.bubble_bitmap_size)
+        iconBitmapSize = context.resources.getDimensionPixelSize(
+                R.dimen.bubble_overflow_icon_bitmap_size)
+        val bubbleSize = context.resources.getDimensionPixelSize(R.dimen.individual_bubble_size)
+        overflowBtn.setLayoutParams(FrameLayout.LayoutParams(bubbleSize, bubbleSize))
+        expandedView.updateDimensions()
+    }
+
+    private fun updateBtnTheme() {
+        val res = context.resources
+
+        // Set overflow button accent color, dot color
+        val typedValue = TypedValue()
+        context.theme.resolveAttribute(android.R.attr.colorAccent, typedValue, true)
+        val colorAccent = res.getColor(typedValue.resourceId)
+        overflowBtn.drawable?.setTint(colorAccent)
+        dotColor = colorAccent
+
+        val iconFactory = BubbleIconFactory(context)
+
+        // Update bitmap
+        val nightMode = (res.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+            == Configuration.UI_MODE_NIGHT_YES)
+        val bg = ColorDrawable(res.getColor(
+            if (nightMode) R.color.bubbles_dark else R.color.bubbles_light))
+
+        val fg = InsetDrawable(overflowBtn.drawable,
+            bitmapSize - iconBitmapSize /* inset */)
+        bitmap = iconFactory.createBadgedIconBitmap(AdaptiveIconDrawable(bg, fg),
+            null /* user */, true /* shrinkNonAdaptiveIcons */).icon
+
+        // Update dot path
+        dotPath = PathParser.createPathFromPathData(
+            res.getString(com.android.internal.R.string.config_icon_mask))
+        val scale = iconFactory.normalizer.getScale(overflowBtn.getDrawable(),
+            null /* outBounds */, null /* path */, null /* outMaskShape */)
+        val radius = BadgedImageView.DEFAULT_PATH_SIZE / 2f
+        val matrix = Matrix()
+        matrix.setScale(scale /* x scale */, scale /* y scale */, radius /* pivot x */,
+            radius /* pivot y */)
+        dotPath.transform(matrix)
+
+        // Attach BubbleOverflow to BadgedImageView
+        overflowBtn.setRenderedBubble(this)
+        overflowBtn.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE)
+    }
+
+    fun setVisible(visible: Int) {
+        overflowBtn.visibility = visible
+    }
+
+    fun setShowDot(show: Boolean) {
+        showDot = show
+        overflowBtn.updateDotVisibility(true /* animate */)
+    }
+
+    override fun getExpandedView(): BubbleExpandedView? {
+        return expandedView
+    }
+
+    override fun getDotColor(): Int {
+        return dotColor
+    }
+
+    override fun getAppBadge(): Drawable? {
+        return null
+    }
+
+    override fun getBubbleIcon(): Bitmap {
+        return bitmap
+    }
+
+    override fun showDot(): Boolean {
+        return showDot
+    }
+
+    override fun getDotPath(): Path? {
+        return dotPath
+    }
+
+    override fun setContentVisibility(visible: Boolean) {
+        expandedView.setContentVisibility(visible)
+    }
+
+    override fun getIconView(): View? {
+        return overflowBtn
+    }
+
+    override fun getKey(): String {
+        return KEY
+    }
+
+    override fun getTaskId(): Int {
+        return if (expandedView != null) expandedView.getTaskId() else INVALID_TASK_ID
+    }
+
+    companion object {
+        @JvmField val KEY = "Overflow"
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflowActivity.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflowActivity.java
new file mode 100644
index 0000000..2759b59
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleOverflowActivity.java
@@ -0,0 +1,357 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_OVERFLOW;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.internal.util.ContrastColorUtil;
+import com.android.wm.shell.R;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Activity for showing aged out bubbles.
+ * Must be public to be accessible to androidx...AppComponentFactory
+ */
+public class BubbleOverflowActivity extends Activity {
+    static final String EXTRA_BUBBLE_CONTROLLER = "bubble_controller";
+
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleOverflowActivity" : TAG_BUBBLES;
+
+    private LinearLayout mEmptyState;
+    private TextView mEmptyStateTitle;
+    private TextView mEmptyStateSubtitle;
+    private ImageView mEmptyStateImage;
+    private BubbleController mController;
+    private BubbleOverflowAdapter mAdapter;
+    private RecyclerView mRecyclerView;
+    private List<Bubble> mOverflowBubbles = new ArrayList<>();
+
+    private class NoScrollGridLayoutManager extends GridLayoutManager {
+        NoScrollGridLayoutManager(Context context, int columns) {
+            super(context, columns);
+        }
+        @Override
+        public boolean canScrollVertically() {
+            if (getResources().getConfiguration().orientation
+                    == Configuration.ORIENTATION_LANDSCAPE) {
+                return super.canScrollVertically();
+            }
+            return false;
+        }
+
+        @Override
+        public int getColumnCountForAccessibility(RecyclerView.Recycler recycler,
+                RecyclerView.State state) {
+            int bubbleCount = state.getItemCount();
+            int columnCount = super.getColumnCountForAccessibility(recycler, state);
+            if (bubbleCount < columnCount) {
+                // If there are 4 columns and bubbles <= 3,
+                // TalkBack says "AppName 1 of 4 in list 4 items"
+                // This is a workaround until TalkBack bug is fixed for GridLayoutManager
+                return bubbleCount;
+            }
+            return columnCount;
+        }
+    }
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.bubble_overflow_activity);
+
+        mRecyclerView = findViewById(R.id.bubble_overflow_recycler);
+        mEmptyState = findViewById(R.id.bubble_overflow_empty_state);
+        mEmptyStateTitle = findViewById(R.id.bubble_overflow_empty_title);
+        mEmptyStateSubtitle = findViewById(R.id.bubble_overflow_empty_subtitle);
+        mEmptyStateImage = findViewById(R.id.bubble_overflow_empty_state_image);
+
+        Intent intent = getIntent();
+        if (intent != null && intent.getExtras() != null) {
+            IBinder binder = intent.getExtras().getBinder(EXTRA_BUBBLE_CONTROLLER);
+            if (binder instanceof ObjectWrapper) {
+                mController = ((ObjectWrapper<BubbleController>) binder).get();
+            }
+        } else {
+            Log.w(TAG, "Bubble overflow activity created without bubble controller!");
+        }
+        updateOverflow();
+    }
+
+    void updateOverflow() {
+        Resources res = getResources();
+        final int columns = res.getInteger(R.integer.bubbles_overflow_columns);
+        mRecyclerView.setLayoutManager(
+                new NoScrollGridLayoutManager(getApplicationContext(), columns));
+
+        DisplayMetrics displayMetrics = new DisplayMetrics();
+        getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
+
+        final int overflowPadding = res.getDimensionPixelSize(R.dimen.bubble_overflow_padding);
+        final int recyclerViewWidth = displayMetrics.widthPixels - (overflowPadding * 2);
+        final int viewWidth = recyclerViewWidth / columns;
+
+        final int maxOverflowBubbles = res.getInteger(R.integer.bubbles_max_overflow);
+        final int rows = (int) Math.ceil((double) maxOverflowBubbles / columns);
+        final int recyclerViewHeight = res.getDimensionPixelSize(R.dimen.bubble_overflow_height)
+                - res.getDimensionPixelSize(R.dimen.bubble_overflow_padding);
+        final int viewHeight = recyclerViewHeight / rows;
+
+        mAdapter = new BubbleOverflowAdapter(getApplicationContext(), mOverflowBubbles,
+                mController::promoteBubbleFromOverflow, viewWidth, viewHeight);
+        mRecyclerView.setAdapter(mAdapter);
+
+        mOverflowBubbles.clear();
+        mOverflowBubbles.addAll(mController.getOverflowBubbles());
+        mAdapter.notifyDataSetChanged();
+        updateEmptyStateVisibility();
+
+        mController.setOverflowListener(mDataListener);
+        updateTheme();
+    }
+
+    void updateEmptyStateVisibility() {
+        if (mOverflowBubbles.isEmpty()) {
+            mEmptyState.setVisibility(View.VISIBLE);
+        } else {
+            mEmptyState.setVisibility(View.GONE);
+        }
+    }
+
+    /**
+     * Handle theme changes.
+     */
+    void updateTheme() {
+        Resources res = getResources();
+        final int mode = res.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
+        final boolean isNightMode = (mode == Configuration.UI_MODE_NIGHT_YES);
+
+        mEmptyStateImage.setImageDrawable(isNightMode
+                ? res.getDrawable(R.drawable.bubble_ic_empty_overflow_dark)
+                : res.getDrawable(R.drawable.bubble_ic_empty_overflow_light));
+
+        findViewById(android.R.id.content)
+                .setBackgroundColor(isNightMode
+                        ? res.getColor(R.color.bubbles_dark)
+                        : res.getColor(R.color.bubbles_light));
+
+        final TypedArray typedArray = getApplicationContext().obtainStyledAttributes(
+                new int[]{android.R.attr.colorBackgroundFloating,
+                        android.R.attr.textColorSecondary});
+        int bgColor = typedArray.getColor(0, isNightMode ? Color.BLACK : Color.WHITE);
+        int textColor = typedArray.getColor(1, isNightMode ? Color.WHITE : Color.BLACK);
+        textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, isNightMode);
+        typedArray.recycle();
+
+        mEmptyStateTitle.setTextColor(textColor);
+        mEmptyStateSubtitle.setTextColor(textColor);
+    }
+
+    private final BubbleData.Listener mDataListener = new BubbleData.Listener() {
+
+        @Override
+        public void applyUpdate(BubbleData.Update update) {
+
+            Bubble toRemove = update.removedOverflowBubble;
+            if (toRemove != null) {
+                if (DEBUG_OVERFLOW) {
+                    Log.d(TAG, "remove: " + toRemove);
+                }
+                toRemove.cleanupViews();
+                final int i = mOverflowBubbles.indexOf(toRemove);
+                mOverflowBubbles.remove(toRemove);
+                mAdapter.notifyItemRemoved(i);
+            }
+
+            Bubble toAdd = update.addedOverflowBubble;
+            if (toAdd != null) {
+                if (DEBUG_OVERFLOW) {
+                    Log.d(TAG, "add: " + toAdd);
+                }
+                mOverflowBubbles.add(0, toAdd);
+                mAdapter.notifyItemInserted(0);
+            }
+
+            updateEmptyStateVisibility();
+
+            if (DEBUG_OVERFLOW) {
+                Log.d(TAG, BubbleDebugConfig.formatBubblesString(
+                        mController.getOverflowBubbles(), null));
+            }
+        }
+    };
+
+    @Override
+    public void onStart() {
+        super.onStart();
+    }
+
+    @Override
+    public void onRestart() {
+        super.onRestart();
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        updateOverflow();
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+    }
+
+    public void onDestroy() {
+        super.onDestroy();
+    }
+}
+
+class BubbleOverflowAdapter extends RecyclerView.Adapter<BubbleOverflowAdapter.ViewHolder> {
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleOverflowAdapter" : TAG_BUBBLES;
+
+    private Context mContext;
+    private Consumer<Bubble> mPromoteBubbleFromOverflow;
+    private List<Bubble> mBubbles;
+    private int mWidth;
+    private int mHeight;
+
+    public BubbleOverflowAdapter(Context context, List<Bubble> list, Consumer<Bubble> promoteBubble,
+            int width, int height) {
+        mContext = context;
+        mBubbles = list;
+        mPromoteBubbleFromOverflow = promoteBubble;
+        mWidth = width;
+        mHeight = height;
+    }
+
+    @Override
+    public BubbleOverflowAdapter.ViewHolder onCreateViewHolder(ViewGroup parent,
+            int viewType) {
+
+        // Set layout for overflow bubble view.
+        LinearLayout overflowView = (LinearLayout) LayoutInflater.from(parent.getContext())
+                .inflate(R.layout.bubble_overflow_view, parent, false);
+        LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+                LinearLayout.LayoutParams.WRAP_CONTENT,
+                LinearLayout.LayoutParams.WRAP_CONTENT);
+        params.width = mWidth;
+        params.height = mHeight;
+        overflowView.setLayoutParams(params);
+
+        // Ensure name has enough contrast.
+        final TypedArray ta = mContext.obtainStyledAttributes(
+                new int[]{android.R.attr.colorBackgroundFloating, android.R.attr.textColorPrimary});
+        final int bgColor = ta.getColor(0, Color.WHITE);
+        int textColor = ta.getColor(1, Color.BLACK);
+        textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, true);
+        ta.recycle();
+
+        TextView viewName = overflowView.findViewById(R.id.bubble_view_name);
+        viewName.setTextColor(textColor);
+
+        return new ViewHolder(overflowView);
+    }
+
+    @Override
+    public void onBindViewHolder(ViewHolder vh, int index) {
+        Bubble b = mBubbles.get(index);
+
+        vh.iconView.setRenderedBubble(b);
+        vh.iconView.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
+        vh.iconView.setOnClickListener(view -> {
+            mBubbles.remove(b);
+            notifyDataSetChanged();
+            mPromoteBubbleFromOverflow.accept(b);
+        });
+
+        String titleStr = b.getTitle();
+        if (titleStr == null) {
+            titleStr = mContext.getResources().getString(R.string.notification_bubble_title);
+        }
+        vh.iconView.setContentDescription(mContext.getResources().getString(
+                R.string.bubble_content_description_single, titleStr, b.getAppName()));
+
+        vh.iconView.setAccessibilityDelegate(
+                new View.AccessibilityDelegate() {
+                    @Override
+                    public void onInitializeAccessibilityNodeInfo(View host,
+                            AccessibilityNodeInfo info) {
+                        super.onInitializeAccessibilityNodeInfo(host, info);
+                        // Talkback prompts "Double tap to add back to stack"
+                        // instead of the default "Double tap to activate"
+                        info.addAction(
+                                new AccessibilityNodeInfo.AccessibilityAction(
+                                        AccessibilityNodeInfo.ACTION_CLICK,
+                                        mContext.getResources().getString(
+                                                R.string.bubble_accessibility_action_add_back)));
+                    }
+                });
+
+        CharSequence label = b.getShortcutInfo() != null
+                ? b.getShortcutInfo().getLabel()
+                : b.getAppName();
+        vh.textView.setText(label);
+    }
+
+    @Override
+    public int getItemCount() {
+        return mBubbles.size();
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        public BadgedImageView iconView;
+        public TextView textView;
+
+        public ViewHolder(LinearLayout v) {
+            super(v);
+            iconView = v.findViewById(R.id.bubble_view);
+            textView = v.findViewById(R.id.bubble_view_name);
+        }
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
new file mode 100644
index 0000000..eccd009
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Insets;
+import android.graphics.Rect;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.view.WindowMetrics;
+
+import androidx.annotation.VisibleForTesting;
+
+/**
+ * Keeps track of display size, configuration, and specific bubble sizes. One place for all
+ * placement and positioning calculations to refer to.
+ */
+public class BubblePositioner {
+
+    private WindowManager mWindowManager;
+    private Rect mPositionRect;
+    private int mOrientation;
+    private Insets mInsets;
+
+    public BubblePositioner(Context context, WindowManager windowManager) {
+        mWindowManager = windowManager;
+        update(Configuration.ORIENTATION_UNDEFINED);
+    }
+
+    public void update(int orientation) {
+        WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics();
+        mPositionRect = new Rect(windowMetrics.getBounds());
+        WindowInsets metricInsets = windowMetrics.getWindowInsets();
+
+        Insets insets = metricInsets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars()
+                | WindowInsets.Type.statusBars()
+                | WindowInsets.Type.displayCutout());
+        update(orientation, insets, windowMetrics.getBounds());
+    }
+
+    @VisibleForTesting
+    public void update(int orientation, Insets insets, Rect bounds) {
+        mOrientation = orientation;
+        mInsets = insets;
+
+        mPositionRect = new Rect(bounds);
+        mPositionRect.left += mInsets.left;
+        mPositionRect.top += mInsets.top;
+        mPositionRect.right -= mInsets.right;
+        mPositionRect.bottom -= mInsets.bottom;
+    }
+
+    /**
+     * @return a rect of available screen space for displaying bubbles in the correct orientation,
+     * accounting for system bars and cutouts.
+     */
+    public Rect getAvailableRect() {
+        return mPositionRect;
+    }
+
+    /**
+     * @return the current orientation.
+     */
+    public int getOrientation() {
+        return mOrientation;
+    }
+
+    /**
+     * @return the relevant insets (status bar, nav bar, cutouts).
+     */
+    public Insets getInsets() {
+        return mInsets;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
new file mode 100644
index 0000000..155f342
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
@@ -0,0 +1,2752 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_STACK_VIEW;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.ColorMatrix;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Insets;
+import android.graphics.Outline;
+import android.graphics.Paint;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.Choreographer;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.view.ViewTreeObserver;
+import android.view.WindowInsets;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.FrameworkStatsLog;
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix;
+import com.android.wm.shell.bubbles.animation.ExpandedAnimationController;
+import com.android.wm.shell.bubbles.animation.PhysicsAnimationLayout;
+import com.android.wm.shell.bubbles.animation.StackAnimationController;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * Renders bubbles in a stack and handles animating expanded and collapsed states.
+ */
+public class BubbleStackView extends FrameLayout
+        implements ViewTreeObserver.OnComputeInternalInsetsListener {
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleStackView" : TAG_BUBBLES;
+
+    /** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */
+    static final float FLYOUT_DRAG_PERCENT_DISMISS = 0.25f;
+
+    /** Velocity required to dismiss the flyout via drag. */
+    private static final float FLYOUT_DISMISS_VELOCITY = 2000f;
+
+    /**
+     * Factor for attenuating translation when the flyout is overscrolled (8f = flyout moves 1 pixel
+     * for every 8 pixels overscrolled).
+     */
+    private static final float FLYOUT_OVERSCROLL_ATTENUATION_FACTOR = 8f;
+
+    /** Duration of the flyout alpha animations. */
+    private static final int FLYOUT_ALPHA_ANIMATION_DURATION = 100;
+
+    private static final int FADE_IN_DURATION = 320;
+
+    /** Percent to darken the bubbles when they're in the dismiss target. */
+    private static final float DARKEN_PERCENT = 0.3f;
+
+    /** How long to wait, in milliseconds, before hiding the flyout. */
+    @VisibleForTesting
+    static final int FLYOUT_HIDE_AFTER = 5000;
+
+    /**
+     * How long to wait to animate the stack temporarily invisible after a drag/flyout hide
+     * animation ends, if we are in fact temporarily invisible.
+     */
+    private static final int ANIMATE_TEMPORARILY_INVISIBLE_DELAY = 1000;
+
+    private static final PhysicsAnimator.SpringConfig FLYOUT_IME_ANIMATION_SPRING_CONFIG =
+            new PhysicsAnimator.SpringConfig(
+                    StackAnimationController.IME_ANIMATION_STIFFNESS,
+                    StackAnimationController.DEFAULT_BOUNCINESS);
+
+    private final PhysicsAnimator.SpringConfig mScaleInSpringConfig =
+            new PhysicsAnimator.SpringConfig(300f, 0.9f);
+
+    private final PhysicsAnimator.SpringConfig mScaleOutSpringConfig =
+            new PhysicsAnimator.SpringConfig(900f, 1f);
+
+    private final PhysicsAnimator.SpringConfig mTranslateSpringConfig =
+            new PhysicsAnimator.SpringConfig(
+                    SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY);
+
+    /**
+     * Handler to use for all delayed animations - this way, we can easily cancel them before
+     * starting a new animation.
+     */
+    private final Handler mDelayedAnimationHandler = new Handler();
+
+    /**
+     * Interface to synchronize {@link View} state and the screen.
+     *
+     * {@hide}
+     */
+    public interface SurfaceSynchronizer {
+        /**
+         * Wait until requested change on a {@link View} is reflected on the screen.
+         *
+         * @param callback callback to run after the change is reflected on the screen.
+         */
+        void syncSurfaceAndRun(Runnable callback);
+    }
+
+    private static final SurfaceSynchronizer DEFAULT_SURFACE_SYNCHRONIZER =
+            new SurfaceSynchronizer() {
+        @Override
+        public void syncSurfaceAndRun(Runnable callback) {
+            Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
+                // Just wait 2 frames. There is no guarantee, but this is usually enough time that
+                // the requested change is reflected on the screen.
+                // TODO: Once SurfaceFlinger provide APIs to sync the state of {@code View} and
+                // surfaces, rewrite this logic with them.
+                private int mFrameWait = 2;
+
+                @Override
+                public void doFrame(long frameTimeNanos) {
+                    if (--mFrameWait > 0) {
+                        Choreographer.getInstance().postFrameCallback(this);
+                    } else {
+                        callback.run();
+                    }
+                }
+            });
+        }
+    };
+    private final BubbleController mBubbleController;
+    private final BubbleData mBubbleData;
+
+    private final ValueAnimator mDesaturateAndDarkenAnimator;
+    private final Paint mDesaturateAndDarkenPaint = new Paint();
+
+    private PhysicsAnimationLayout mBubbleContainer;
+    private StackAnimationController mStackAnimationController;
+    private ExpandedAnimationController mExpandedAnimationController;
+
+    private FrameLayout mExpandedViewContainer;
+
+    /** Matrix used to scale the expanded view container with a given pivot point. */
+    private final AnimatableScaleMatrix mExpandedViewContainerMatrix = new AnimatableScaleMatrix();
+
+    /**
+     * SurfaceView that we draw screenshots of animating-out bubbles into. This allows us to animate
+     * between bubble activities without needing both to be alive at the same time.
+     */
+    private SurfaceView mAnimatingOutSurfaceView;
+
+    /** Container for the animating-out SurfaceView. */
+    private FrameLayout mAnimatingOutSurfaceContainer;
+
+    /**
+     * Buffer containing a screenshot of the animating-out bubble. This is drawn into the
+     * SurfaceView during animations.
+     */
+    private SurfaceControl.ScreenshotHardwareBuffer mAnimatingOutBubbleBuffer;
+
+    private BubbleFlyoutView mFlyout;
+    /** Runnable that fades out the flyout and then sets it to GONE. */
+    private Runnable mHideFlyout = () -> animateFlyoutCollapsed(true, 0 /* velX */);
+    /**
+     * Callback to run after the flyout hides. Also called if a new flyout is shown before the
+     * previous one animates out.
+     */
+    private Runnable mAfterFlyoutHidden;
+    /**
+     * Set when the flyout is tapped, so that we can expand the bubble associated with the flyout
+     * once it collapses.
+     */
+    @Nullable
+    private Bubble mBubbleToExpandAfterFlyoutCollapse = null;
+
+    /** Layout change listener that moves the stack to the nearest valid position on rotation. */
+    private OnLayoutChangeListener mOrientationChangedListener;
+
+    @Nullable private RelativeStackPosition mRelativeStackPositionBeforeRotation;
+
+    private int mMaxBubbles;
+    private int mBubbleSize;
+    private int mBubbleElevation;
+    private int mBubblePaddingTop;
+    private int mBubbleTouchPadding;
+    private int mExpandedViewPadding;
+    private int mPointerHeight;
+    private int mCornerRadius;
+    private int mImeOffset;
+    @Nullable private BubbleViewProvider mExpandedBubble;
+    private boolean mIsExpanded;
+
+    /** Whether the stack is currently on the left side of the screen, or animating there. */
+    private boolean mStackOnLeftOrWillBe = true;
+
+    /** Whether a touch gesture, such as a stack/bubble drag or flyout drag, is in progress. */
+    private boolean mIsGestureInProgress = false;
+
+    /** Whether or not the stack is temporarily invisible off the side of the screen. */
+    private boolean mTemporarilyInvisible = false;
+
+    /** Whether we're in the middle of dragging the stack around by touch. */
+    private boolean mIsDraggingStack = false;
+
+    /**
+     * The pointer index of the ACTION_DOWN event we received prior to an ACTION_UP. We'll ignore
+     * touches from other pointer indices.
+     */
+    private int mPointerIndexDown = -1;
+
+    /** Description of current animation controller state. */
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println("Stack view state:");
+
+        String bubblesOnScreen = BubbleDebugConfig.formatBubblesString(
+                getBubblesOnScreen(), getExpandedBubble());
+        pw.print("  bubbles on screen:       "); pw.println(bubblesOnScreen);
+        pw.print("  gestureInProgress:       "); pw.println(mIsGestureInProgress);
+        pw.print("  showingDismiss:          "); pw.println(mDismissView.isShowing());
+        pw.print("  isExpansionAnimating:    "); pw.println(mIsExpansionAnimating);
+        pw.print("  expandedContainerVis:    "); pw.println(mExpandedViewContainer.getVisibility());
+        pw.print("  expandedContainerAlpha:  "); pw.println(mExpandedViewContainer.getAlpha());
+        pw.print("  expandedContainerMatrix: ");
+        pw.println(mExpandedViewContainer.getAnimationMatrix());
+
+        mStackAnimationController.dump(fd, pw, args);
+        mExpandedAnimationController.dump(fd, pw, args);
+
+        if (mExpandedBubble != null) {
+            pw.println("Expanded bubble state:");
+            pw.println("  expandedBubbleKey: " + mExpandedBubble.getKey());
+
+            final BubbleExpandedView expandedView = mExpandedBubble.getExpandedView();
+
+            if (expandedView != null) {
+                pw.println("  expandedViewVis:    " + expandedView.getVisibility());
+                pw.println("  expandedViewAlpha:  " + expandedView.getAlpha());
+                pw.println("  expandedViewTaskId: " + expandedView.getTaskId());
+
+                final View av = expandedView.getTaskView();
+
+                if (av != null) {
+                    pw.println("  activityViewVis:    " + av.getVisibility());
+                    pw.println("  activityViewAlpha:  " + av.getAlpha());
+                } else {
+                    pw.println("  activityView is null");
+                }
+            } else {
+                pw.println("Expanded bubble view state: expanded bubble view is null");
+            }
+        } else {
+            pw.println("Expanded bubble state: expanded bubble is null");
+        }
+    }
+
+    private BubbleController.BubbleExpandListener mExpandListener;
+
+    /** Callback to run when we want to unbubble the given notification's conversation. */
+    private Consumer<String> mUnbubbleConversationCallback;
+
+    private boolean mViewUpdatedRequested = false;
+    private boolean mIsExpansionAnimating = false;
+    private boolean mIsBubbleSwitchAnimating = false;
+
+    /** The view to desaturate/darken when magneted to the dismiss target. */
+    @Nullable private View mDesaturateAndDarkenTargetView;
+
+    private Rect mTempRect = new Rect();
+
+    private final List<Rect> mSystemGestureExclusionRects = Collections.singletonList(new Rect());
+
+    private ViewTreeObserver.OnPreDrawListener mViewUpdater =
+            new ViewTreeObserver.OnPreDrawListener() {
+                @Override
+                public boolean onPreDraw() {
+                    getViewTreeObserver().removeOnPreDrawListener(mViewUpdater);
+                    updateExpandedView();
+                    mViewUpdatedRequested = false;
+                    return true;
+                }
+            };
+
+    private ViewTreeObserver.OnDrawListener mSystemGestureExcludeUpdater =
+            this::updateSystemGestureExcludeRects;
+
+    /** Float property that 'drags' the flyout. */
+    private final FloatPropertyCompat mFlyoutCollapseProperty =
+            new FloatPropertyCompat("FlyoutCollapseSpring") {
+                @Override
+                public float getValue(Object o) {
+                    return mFlyoutDragDeltaX;
+                }
+
+                @Override
+                public void setValue(Object o, float v) {
+                    setFlyoutStateForDragLength(v);
+                }
+            };
+
+    /** SpringAnimation that springs the flyout collapsed via onFlyoutDragged. */
+    private final SpringAnimation mFlyoutTransitionSpring =
+            new SpringAnimation(this, mFlyoutCollapseProperty);
+
+    /** Distance the flyout has been dragged in the X axis. */
+    private float mFlyoutDragDeltaX = 0f;
+
+    /**
+     * Runnable that animates in the flyout. This reference is needed to cancel delayed postings.
+     */
+    private Runnable mAnimateInFlyout;
+
+    /**
+     * End listener for the flyout spring that either posts a runnable to hide the flyout, or hides
+     * it immediately.
+     */
+    private final DynamicAnimation.OnAnimationEndListener mAfterFlyoutTransitionSpring =
+            (dynamicAnimation, b, v, v1) -> {
+                if (mFlyoutDragDeltaX == 0) {
+                    mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
+                } else {
+                    mFlyout.hideFlyout();
+                }
+            };
+
+    @NonNull
+    private final SurfaceSynchronizer mSurfaceSynchronizer;
+
+    /**
+     * The currently magnetized object, which is being dragged and will be attracted to the magnetic
+     * dismiss target.
+     *
+     * This is either the stack itself, or an individual bubble.
+     */
+    private MagnetizedObject<?> mMagnetizedObject;
+
+    /**
+     * The MagneticTarget instance for our circular dismiss view. This is added to the
+     * MagnetizedObject instances for the stack and any dragged-out bubbles.
+     */
+    private MagnetizedObject.MagneticTarget mMagneticTarget;
+
+    /** Magnet listener that handles animating and dismissing individual dragged-out bubbles. */
+    private final MagnetizedObject.MagnetListener mIndividualBubbleMagnetListener =
+            new MagnetizedObject.MagnetListener() {
+                @Override
+                public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) {
+                    if (mExpandedAnimationController.getDraggedOutBubble() == null) {
+                        return;
+                    }
+
+                    animateDesaturateAndDarken(
+                            mExpandedAnimationController.getDraggedOutBubble(), true);
+                }
+
+                @Override
+                public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target,
+                        float velX, float velY, boolean wasFlungOut) {
+                    if (mExpandedAnimationController.getDraggedOutBubble() == null) {
+                        return;
+                    }
+
+                    animateDesaturateAndDarken(
+                            mExpandedAnimationController.getDraggedOutBubble(), false);
+
+                    if (wasFlungOut) {
+                        mExpandedAnimationController.snapBubbleBack(
+                                mExpandedAnimationController.getDraggedOutBubble(), velX, velY);
+                        mDismissView.hide();
+                    } else {
+                        mExpandedAnimationController.onUnstuckFromTarget();
+                    }
+                }
+
+                @Override
+                public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) {
+                    if (mExpandedAnimationController.getDraggedOutBubble() == null) {
+                        return;
+                    }
+
+                    mExpandedAnimationController.dismissDraggedOutBubble(
+                            mExpandedAnimationController.getDraggedOutBubble() /* bubble */,
+                            mDismissView.getHeight() /* translationYBy */,
+                            BubbleStackView.this::dismissMagnetizedObject /* after */);
+                    mDismissView.hide();
+                }
+            };
+
+    /** Magnet listener that handles animating and dismissing the entire stack. */
+    private final MagnetizedObject.MagnetListener mStackMagnetListener =
+            new MagnetizedObject.MagnetListener() {
+                @Override
+                public void onStuckToTarget(
+                        @NonNull MagnetizedObject.MagneticTarget target) {
+                    animateDesaturateAndDarken(mBubbleContainer, true);
+                }
+
+                @Override
+                public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target,
+                        float velX, float velY, boolean wasFlungOut) {
+                    animateDesaturateAndDarken(mBubbleContainer, false);
+
+                    if (wasFlungOut) {
+                        mStackAnimationController.flingStackThenSpringToEdge(
+                                mStackAnimationController.getStackPosition().x, velX, velY);
+                        mDismissView.hide();
+                    } else {
+                        mStackAnimationController.onUnstuckFromTarget();
+                    }
+                }
+
+                @Override
+                public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) {
+                    mStackAnimationController.animateStackDismissal(
+                            mDismissView.getHeight() /* translationYBy */,
+                            () -> {
+                                resetDesaturationAndDarken();
+                                dismissMagnetizedObject();
+                            }
+                    );
+
+                    mDismissView.hide();
+                }
+            };
+
+    /**
+     * Click listener set on each bubble view. When collapsed, clicking a bubble expands the stack.
+     * When expanded, clicking a bubble either expands that bubble, or collapses the stack.
+     */
+    private OnClickListener mBubbleClickListener = new OnClickListener() {
+        @Override
+        public void onClick(View view) {
+            mIsDraggingStack = false; // If the touch ended in a click, we're no longer dragging.
+
+            // Bubble clicks either trigger expansion/collapse or a bubble switch, both of which we
+            // shouldn't interrupt. These are quick transitions, so it's not worth trying to adjust
+            // the animations inflight.
+            if (mIsExpansionAnimating || mIsBubbleSwitchAnimating) {
+                return;
+            }
+
+            final Bubble clickedBubble = mBubbleData.getBubbleWithView(view);
+
+            // If the bubble has since left us, ignore the click.
+            if (clickedBubble == null) {
+                return;
+            }
+
+            final boolean clickedBubbleIsCurrentlyExpandedBubble =
+                    clickedBubble.getKey().equals(mExpandedBubble.getKey());
+
+            if (isExpanded()) {
+                mExpandedAnimationController.onGestureFinished();
+            }
+
+            if (isExpanded() && !clickedBubbleIsCurrentlyExpandedBubble) {
+                if (clickedBubble != mBubbleData.getSelectedBubble()) {
+                    // Select the clicked bubble.
+                    mBubbleData.setSelectedBubble(clickedBubble);
+                } else {
+                    // If the clicked bubble is the selected bubble (but not the expanded bubble),
+                    // that means overflow was previously expanded. Set the selected bubble
+                    // internally without going through BubbleData (which would ignore it since it's
+                    // already selected).
+                    setSelectedBubble(clickedBubble);
+                }
+            } else {
+                // Otherwise, we either tapped the stack (which means we're collapsed
+                // and should expand) or the currently selected bubble (we're expanded
+                // and should collapse).
+                if (!maybeShowStackEdu()) {
+                    mBubbleData.setExpanded(!mBubbleData.isExpanded());
+                }
+            }
+        }
+    };
+
+    /**
+     * Touch listener set on each bubble view. This enables dragging and dismissing the stack (when
+     * collapsed), or individual bubbles (when expanded).
+     */
+    private RelativeTouchListener mBubbleTouchListener = new RelativeTouchListener() {
+
+        @Override
+        public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) {
+            // If we're expanding or collapsing, consume but ignore all touch events.
+            if (mIsExpansionAnimating) {
+                return true;
+            }
+
+            // If the manage menu is visible, just hide it.
+            if (mShowingManage) {
+                showManageMenu(false /* show */);
+            }
+
+            if (mBubbleData.isExpanded()) {
+                if (mManageEduView != null) {
+                    mManageEduView.hide(false /* show */);
+                }
+
+                // If we're expanded, tell the animation controller to prepare to drag this bubble,
+                // dispatching to the individual bubble magnet listener.
+                mExpandedAnimationController.prepareForBubbleDrag(
+                        v /* bubble */,
+                        mMagneticTarget,
+                        mIndividualBubbleMagnetListener);
+
+                hideCurrentInputMethod();
+
+                // Save the magnetized individual bubble so we can dispatch touch events to it.
+                mMagnetizedObject = mExpandedAnimationController.getMagnetizedBubbleDraggingOut();
+            } else {
+                // If we're collapsed, prepare to drag the stack. Cancel active animations, set the
+                // animation controller, and hide the flyout.
+                mStackAnimationController.cancelStackPositionAnimations();
+                mBubbleContainer.setActiveController(mStackAnimationController);
+                hideFlyoutImmediate();
+
+                // Also, save the magnetized stack so we can dispatch touch events to it.
+                mMagnetizedObject = mStackAnimationController.getMagnetizedStack(mMagneticTarget);
+                mMagnetizedObject.setMagnetListener(mStackMagnetListener);
+
+                mIsDraggingStack = true;
+
+                // Cancel animations to make the stack temporarily invisible, since we're now
+                // dragging it.
+                updateTemporarilyInvisibleAnimation(false /* hideImmediately */);
+            }
+
+            passEventToMagnetizedObject(ev);
+
+            // Bubbles are always interested in all touch events!
+            return true;
+        }
+
+        @Override
+        public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy) {
+            // If we're expanding or collapsing, ignore all touch events.
+            if (mIsExpansionAnimating) {
+                return;
+            }
+
+            // Show the dismiss target, if we haven't already.
+            mDismissView.show();
+
+            // First, see if the magnetized object consumes the event - if so, we shouldn't move the
+            // bubble since it's stuck to the target.
+            if (!passEventToMagnetizedObject(ev)) {
+                if (mBubbleData.isExpanded()) {
+                    mExpandedAnimationController.dragBubbleOut(
+                            v, viewInitialX + dx, viewInitialY + dy);
+                } else {
+                    if (mStackEduView != null) {
+                        mStackEduView.hide(false /* fromExpansion */);
+                    }
+                    mStackAnimationController.moveStackFromTouch(
+                            viewInitialX + dx, viewInitialY + dy);
+                }
+            }
+        }
+
+        @Override
+        public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy, float velX, float velY) {
+            // If we're expanding or collapsing, ignore all touch events.
+            if (mIsExpansionAnimating) {
+                return;
+            }
+
+            // First, see if the magnetized object consumes the event - if so, the bubble was
+            // released in the target or flung out of it, and we should ignore the event.
+            if (!passEventToMagnetizedObject(ev)) {
+                if (mBubbleData.isExpanded()) {
+                    mExpandedAnimationController.snapBubbleBack(v, velX, velY);
+                } else {
+                    // Fling the stack to the edge, and save whether or not it's going to end up on
+                    // the left side of the screen.
+                    mStackOnLeftOrWillBe =
+                            mStackAnimationController.flingStackThenSpringToEdge(
+                                    viewInitialX + dx, velX, velY) <= 0;
+                    updateBubbleIcons();
+                    logBubbleEvent(null /* no bubble associated with bubble stack move */,
+                            FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_MOVED);
+                }
+                mDismissView.hide();
+            }
+
+            mIsDraggingStack = false;
+
+            // Hide the stack after a delay, if needed.
+            updateTemporarilyInvisibleAnimation(false /* hideImmediately */);
+        }
+    };
+
+    /** Click listener set on the flyout, which expands the stack when the flyout is tapped. */
+    private OnClickListener mFlyoutClickListener = new OnClickListener() {
+        @Override
+        public void onClick(View view) {
+            if (maybeShowStackEdu()) {
+                // If we're showing user education, don't open the bubble show the education first
+                mBubbleToExpandAfterFlyoutCollapse = null;
+            } else {
+                mBubbleToExpandAfterFlyoutCollapse = mBubbleData.getSelectedBubble();
+            }
+
+            mFlyout.removeCallbacks(mHideFlyout);
+            mHideFlyout.run();
+        }
+    };
+
+    /** Touch listener for the flyout. This enables the drag-to-dismiss gesture on the flyout. */
+    private RelativeTouchListener mFlyoutTouchListener = new RelativeTouchListener() {
+
+        @Override
+        public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) {
+            mFlyout.removeCallbacks(mHideFlyout);
+            return true;
+        }
+
+        @Override
+        public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy) {
+            setFlyoutStateForDragLength(dx);
+        }
+
+        @Override
+        public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy, float velX, float velY) {
+            final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+            final boolean metRequiredVelocity =
+                    onLeft ? velX < -FLYOUT_DISMISS_VELOCITY : velX > FLYOUT_DISMISS_VELOCITY;
+            final boolean metRequiredDeltaX =
+                    onLeft
+                            ? dx < -mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS
+                            : dx > mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS;
+            final boolean isCancelFling = onLeft ? velX > 0 : velX < 0;
+            final boolean shouldDismiss = metRequiredVelocity
+                    || (metRequiredDeltaX && !isCancelFling);
+
+            mFlyout.removeCallbacks(mHideFlyout);
+            animateFlyoutCollapsed(shouldDismiss, velX);
+
+            maybeShowStackEdu();
+        }
+    };
+
+    @Nullable
+    private BubbleOverflow mBubbleOverflow;
+    private StackEducationView mStackEduView;
+    private ManageEducationView mManageEduView;
+    private DismissView mDismissView;
+
+    private ViewGroup mManageMenu;
+    private ImageView mManageSettingsIcon;
+    private TextView mManageSettingsText;
+    private boolean mShowingManage = false;
+    private PhysicsAnimator.SpringConfig mManageSpringConfig = new PhysicsAnimator.SpringConfig(
+            SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+    private BubblePositioner mPositioner;
+
+    @SuppressLint("ClickableViewAccessibility")
+    public BubbleStackView(Context context, BubbleController bubbleController,
+            BubbleData data, @Nullable SurfaceSynchronizer synchronizer,
+            FloatingContentCoordinator floatingContentCoordinator) {
+        super(context);
+
+        mBubbleController = bubbleController;
+        mBubbleData = data;
+
+        Resources res = getResources();
+        mMaxBubbles = res.getInteger(R.integer.bubbles_max_rendered);
+        mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        mBubbleTouchPadding = res.getDimensionPixelSize(R.dimen.bubble_touch_padding);
+        mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height);
+
+        mImeOffset = res.getDimensionPixelSize(R.dimen.pip_ime_offset);
+
+        mPositioner = mBubbleController.getPositioner();
+
+        mExpandedViewPadding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding);
+        int elevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+
+        final TypedArray ta = mContext.obtainStyledAttributes(
+                new int[] {android.R.attr.dialogCornerRadius});
+        mCornerRadius = ta.getDimensionPixelSize(0, 0);
+        ta.recycle();
+
+        final Runnable onBubbleAnimatedOut = () -> {
+            if (getBubbleCount() == 0) {
+                mBubbleController.onAllBubblesAnimatedOut();
+            }
+        };
+
+        mStackAnimationController = new StackAnimationController(
+                floatingContentCoordinator, this::getBubbleCount, onBubbleAnimatedOut, mPositioner);
+
+        mExpandedAnimationController = new ExpandedAnimationController(
+                mPositioner, mExpandedViewPadding, onBubbleAnimatedOut);
+        mSurfaceSynchronizer = synchronizer != null ? synchronizer : DEFAULT_SURFACE_SYNCHRONIZER;
+
+        // Force LTR by default since most of the Bubbles UI is positioned manually by the user, or
+        // is centered. It greatly simplifies translation positioning/animations. Views that will
+        // actually lay out differently in RTL, such as the flyout and expanded view, will set their
+        // layout direction to LOCALE.
+        setLayoutDirection(LAYOUT_DIRECTION_LTR);
+
+        mBubbleContainer = new PhysicsAnimationLayout(context);
+        mBubbleContainer.setActiveController(mStackAnimationController);
+        mBubbleContainer.setElevation(elevation);
+        mBubbleContainer.setClipChildren(false);
+        addView(mBubbleContainer, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));
+
+        updateUserEdu();
+
+        mExpandedViewContainer = new FrameLayout(context);
+        mExpandedViewContainer.setElevation(elevation);
+        mExpandedViewContainer.setClipChildren(false);
+        addView(mExpandedViewContainer);
+
+        mAnimatingOutSurfaceContainer = new FrameLayout(getContext());
+        mAnimatingOutSurfaceContainer.setLayoutParams(
+                new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+        addView(mAnimatingOutSurfaceContainer);
+
+        mAnimatingOutSurfaceView = new SurfaceView(getContext());
+        mAnimatingOutSurfaceView.setUseAlpha();
+        mAnimatingOutSurfaceView.setZOrderOnTop(true);
+        mAnimatingOutSurfaceView.setCornerRadius(mCornerRadius);
+        mAnimatingOutSurfaceView.setLayoutParams(new ViewGroup.LayoutParams(0, 0));
+        mAnimatingOutSurfaceContainer.addView(mAnimatingOutSurfaceView);
+
+        mAnimatingOutSurfaceContainer.setPadding(
+                mExpandedViewContainer.getPaddingLeft(),
+                mExpandedViewContainer.getPaddingTop(),
+                mExpandedViewContainer.getPaddingRight(),
+                mExpandedViewContainer.getPaddingBottom());
+
+        setUpManageMenu();
+
+        setUpFlyout();
+        mFlyoutTransitionSpring.setSpring(new SpringForce()
+                .setStiffness(SpringForce.STIFFNESS_LOW)
+                .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
+        mFlyoutTransitionSpring.addEndListener(mAfterFlyoutTransitionSpring);
+
+        mDismissView = new DismissView(context);
+        addView(mDismissView);
+
+        final ContentResolver contentResolver = getContext().getContentResolver();
+        final int dismissRadius = Settings.Secure.getInt(
+                contentResolver, "bubble_dismiss_radius", mBubbleSize * 2 /* default */);
+
+        // Save the MagneticTarget instance for the newly set up view - we'll add this to the
+        // MagnetizedObjects.
+        mMagneticTarget = new MagnetizedObject.MagneticTarget(
+                mDismissView.getCircle(), dismissRadius);
+
+        setClipChildren(false);
+        setFocusable(true);
+        mBubbleContainer.bringToFront();
+
+        mBubbleOverflow = new BubbleOverflow(getContext(), bubbleController, this);
+        mBubbleContainer.addView(mBubbleOverflow.getIconView(),
+                mBubbleContainer.getChildCount() /* index */,
+                new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+                        ViewGroup.LayoutParams.WRAP_CONTENT));
+        updateOverflow();
+        mBubbleOverflow.getIconView().setOnClickListener((View v) -> {
+            setSelectedBubble(mBubbleOverflow);
+            showManageMenu(false);
+        });
+
+        setOnApplyWindowInsetsListener((View view, WindowInsets insets) -> {
+            mBubbleController.onImeVisibilityChanged(
+                    insets.getInsets(WindowInsets.Type.ime()).bottom > 0);
+            if (!mIsExpanded || mIsExpansionAnimating) {
+                return view.onApplyWindowInsets(insets);
+            }
+            return view.onApplyWindowInsets(insets);
+        });
+
+        mOrientationChangedListener =
+                (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
+                    onDisplaySizeChanged();
+                    mExpandedAnimationController.updateResources();
+                    mStackAnimationController.updateResources();
+                    mBubbleOverflow.updateResources();
+
+                    if (mRelativeStackPositionBeforeRotation != null) {
+                        mStackAnimationController.setStackPosition(
+                                mRelativeStackPositionBeforeRotation);
+                        mRelativeStackPositionBeforeRotation = null;
+                    }
+
+                    if (mIsExpanded) {
+                        // Re-draw bubble row and pointer for new orientation.
+                        beforeExpandedViewAnimation();
+                        updateOverflowVisibility();
+                        updatePointerPosition();
+                        mExpandedAnimationController.expandFromStack(() -> {
+                            afterExpandedViewAnimation();
+                        } /* after */);
+                        mExpandedViewContainer.setTranslationX(0f);
+                        mExpandedViewContainer.setTranslationY(getExpandedViewY());
+                        mExpandedViewContainer.setAlpha(1f);
+                    }
+                    removeOnLayoutChangeListener(mOrientationChangedListener);
+                };
+
+        // This must be a separate OnDrawListener since it should be called for every draw.
+        getViewTreeObserver().addOnDrawListener(mSystemGestureExcludeUpdater);
+
+        final ColorMatrix animatedMatrix = new ColorMatrix();
+        final ColorMatrix darkenMatrix = new ColorMatrix();
+
+        mDesaturateAndDarkenAnimator = ValueAnimator.ofFloat(1f, 0f);
+        mDesaturateAndDarkenAnimator.addUpdateListener(animation -> {
+            final float animatedValue = (float) animation.getAnimatedValue();
+            animatedMatrix.setSaturation(animatedValue);
+
+            final float animatedDarkenValue = (1f - animatedValue) * DARKEN_PERCENT;
+            darkenMatrix.setScale(
+                    1f - animatedDarkenValue /* red */,
+                    1f - animatedDarkenValue /* green */,
+                    1f - animatedDarkenValue /* blue */,
+                    1f /* alpha */);
+
+            // Concat the matrices so that the animatedMatrix both desaturates and darkens.
+            animatedMatrix.postConcat(darkenMatrix);
+
+            // Update the paint and apply it to the bubble container.
+            mDesaturateAndDarkenPaint.setColorFilter(new ColorMatrixColorFilter(animatedMatrix));
+
+            if (mDesaturateAndDarkenTargetView != null) {
+                mDesaturateAndDarkenTargetView.setLayerPaint(mDesaturateAndDarkenPaint);
+            }
+        });
+
+        // If the stack itself is touched, it means none of its touchable views (bubbles, flyouts,
+        // ActivityViews, etc.) were touched. Collapse the stack if it's expanded.
+        setOnTouchListener((view, ev) -> {
+            if (ev.getAction() == MotionEvent.ACTION_DOWN) {
+                if (mShowingManage) {
+                    showManageMenu(false /* show */);
+                } else if (mBubbleData.isExpanded()) {
+                    mBubbleData.setExpanded(false);
+                }
+            }
+
+            return true;
+        });
+
+        animate()
+                .setInterpolator(Interpolators.PANEL_CLOSE_ACCELERATED)
+                .setDuration(FADE_IN_DURATION);
+    }
+
+    /**
+     * Sets whether or not the stack should become temporarily invisible by moving off the side of
+     * the screen.
+     *
+     * If a flyout comes in while it's invisible, it will animate back in while the flyout is
+     * showing but disappear again when the flyout is gone.
+     */
+    public void setTemporarilyInvisible(boolean invisible) {
+        mTemporarilyInvisible = invisible;
+
+        // If we are animating out, hide immediately if possible so we animate out with the status
+        // bar.
+        updateTemporarilyInvisibleAnimation(invisible /* hideImmediately */);
+    }
+
+    /**
+     * Animates the stack to be temporarily invisible, if needed.
+     *
+     * If we're currently dragging the stack, or a flyout is visible, the stack will remain visible.
+     * regardless of the value of {@link #mTemporarilyInvisible}. This method is called on ACTION_UP
+     * as well as whenever a flyout hides, so we will animate invisible at that point if needed.
+     */
+    private void updateTemporarilyInvisibleAnimation(boolean hideImmediately) {
+        removeCallbacks(mAnimateTemporarilyInvisibleImmediate);
+
+        if (mIsDraggingStack) {
+            // If we're dragging the stack, don't animate it invisible.
+            return;
+        }
+
+        final boolean shouldHide =
+                mTemporarilyInvisible && mFlyout.getVisibility() != View.VISIBLE;
+
+        postDelayed(mAnimateTemporarilyInvisibleImmediate,
+                shouldHide && !hideImmediately ? ANIMATE_TEMPORARILY_INVISIBLE_DELAY : 0);
+    }
+
+    private final Runnable mAnimateTemporarilyInvisibleImmediate = () -> {
+        if (mTemporarilyInvisible && mFlyout.getVisibility() != View.VISIBLE) {
+            if (mStackAnimationController.isStackOnLeftSide()) {
+                animate().translationX(-mBubbleSize).start();
+            } else {
+                animate().translationX(mBubbleSize).start();
+            }
+        } else {
+            animate().translationX(0).start();
+        }
+    };
+
+    private void setUpManageMenu() {
+        if (mManageMenu != null) {
+            removeView(mManageMenu);
+        }
+
+        mManageMenu = (ViewGroup) LayoutInflater.from(getContext()).inflate(
+                R.layout.bubble_manage_menu, this, false);
+        mManageMenu.setVisibility(View.INVISIBLE);
+
+        PhysicsAnimator.getInstance(mManageMenu).setDefaultSpringConfig(mManageSpringConfig);
+
+        mManageMenu.setOutlineProvider(new ViewOutlineProvider() {
+            @Override
+            public void getOutline(View view, Outline outline) {
+                outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
+            }
+        });
+        mManageMenu.setClipToOutline(true);
+
+        mManageMenu.findViewById(R.id.bubble_manage_menu_dismiss_container).setOnClickListener(
+                view -> {
+                    showManageMenu(false /* show */);
+                    dismissBubbleIfExists(mBubbleData.getSelectedBubble());
+                });
+
+        mManageMenu.findViewById(R.id.bubble_manage_menu_dont_bubble_container).setOnClickListener(
+                view -> {
+                    showManageMenu(false /* show */);
+                    mUnbubbleConversationCallback.accept(mBubbleData.getSelectedBubble().getKey());
+                });
+
+        mManageMenu.findViewById(R.id.bubble_manage_menu_settings_container).setOnClickListener(
+                view -> {
+                    showManageMenu(false /* show */);
+                    final Bubble bubble = mBubbleData.getSelectedBubble();
+                    if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+                        final Intent intent = bubble.getSettingsIntent(mContext);
+                        mBubbleData.setExpanded(false);
+                        mContext.startActivityAsUser(intent, bubble.getUser());
+                        logBubbleEvent(bubble,
+                                FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__HEADER_GO_TO_SETTINGS);
+                    }
+                });
+
+        mManageSettingsIcon = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_icon);
+        mManageSettingsText = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_name);
+
+        // The menu itself should respect locale direction so the icons are on the correct side.
+        mManageMenu.setLayoutDirection(LAYOUT_DIRECTION_LOCALE);
+        addView(mManageMenu);
+    }
+
+    /**
+     * Whether the educational view should show for the expanded view "manage" menu.
+     */
+    private boolean shouldShowManageEdu() {
+        final boolean seen = getPrefBoolean(ManageEducationViewKt.PREF_MANAGED_EDUCATION);
+        final boolean shouldShow = (!seen || BubbleDebugConfig.forceShowUserEducation(mContext))
+                && mExpandedBubble != null;
+        if (BubbleDebugConfig.DEBUG_USER_EDUCATION) {
+            Log.d(TAG, "Show manage edu: " + shouldShow);
+        }
+        return shouldShow;
+    }
+
+    private void maybeShowManageEdu() {
+        if (!shouldShowManageEdu()) {
+            return;
+        }
+        if (mManageEduView == null) {
+            mManageEduView = new ManageEducationView(mContext);
+            addView(mManageEduView);
+        }
+        mManageEduView.show(mExpandedBubble.getExpandedView(), mTempRect);
+    }
+
+    /**
+     * Whether education view should show for the collapsed stack.
+     */
+    private boolean shouldShowStackEdu() {
+        final boolean seen = getPrefBoolean(StackEducationViewKt.PREF_STACK_EDUCATION);
+        final boolean shouldShow = !seen || BubbleDebugConfig.forceShowUserEducation(mContext);
+        if (BubbleDebugConfig.DEBUG_USER_EDUCATION) {
+            Log.d(TAG, "Show stack edu: " + shouldShow);
+        }
+        return shouldShow;
+    }
+
+    private boolean getPrefBoolean(String key) {
+        return mContext.getSharedPreferences(mContext.getPackageName(), Context.MODE_PRIVATE)
+                .getBoolean(key, false /* default */);
+    }
+
+    /**
+     * @return true if education view for collapsed stack should show and was not showing before.
+     */
+    private boolean maybeShowStackEdu() {
+        if (!shouldShowStackEdu()) {
+            return false;
+        }
+        if (mStackEduView == null) {
+            mStackEduView = new StackEducationView(mContext);
+            addView(mStackEduView);
+        }
+        return mStackEduView.show(mStackAnimationController.getStartPosition());
+    }
+
+    private void updateUserEdu() {
+        maybeShowStackEdu();
+        if (mManageEduView != null) {
+            mManageEduView.invalidate();
+        }
+        maybeShowManageEdu();
+        if (mStackEduView != null) {
+            mStackEduView.invalidate();
+        }
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    private void setUpFlyout() {
+        if (mFlyout != null) {
+            removeView(mFlyout);
+        }
+        mFlyout = new BubbleFlyoutView(getContext());
+        mFlyout.setVisibility(GONE);
+        mFlyout.setOnClickListener(mFlyoutClickListener);
+        mFlyout.setOnTouchListener(mFlyoutTouchListener);
+        addView(mFlyout, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+    }
+
+    void updateFlyout(float fontScale) {
+        mFlyout.updateFontSize(fontScale);
+    }
+
+    private void updateOverflow() {
+        mBubbleOverflow.update();
+        mBubbleContainer.reorderView(mBubbleOverflow.getIconView(),
+                mBubbleContainer.getChildCount() - 1 /* index */);
+        updateOverflowVisibility();
+    }
+
+    void updateOverflowButtonDot() {
+        for (Bubble b : mBubbleData.getOverflowBubbles()) {
+            if (b.showDot()) {
+                mBubbleOverflow.setShowDot(true);
+                return;
+            }
+        }
+        mBubbleOverflow.setShowDot(false);
+    }
+
+    /**
+     * Handle theme changes.
+     */
+    public void onThemeChanged() {
+        setUpFlyout();
+        setUpManageMenu();
+        updateOverflow();
+        updateUserEdu();
+        updateExpandedViewTheme();
+    }
+
+    /** Respond to the phone being rotated by repositioning the stack and hiding any flyouts. */
+    public void onOrientationChanged() {
+        Resources res = getContext().getResources();
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+
+        mRelativeStackPositionBeforeRotation = mStackAnimationController.getRelativeStackPosition();
+        mManageMenu.setVisibility(View.INVISIBLE);
+        mShowingManage = false;
+
+        addOnLayoutChangeListener(mOrientationChangedListener);
+        hideFlyoutImmediate();
+    }
+
+    /** Tells the views with locale-dependent layout direction to resolve the new direction. */
+    public void onLayoutDirectionChanged(int direction) {
+        mManageMenu.setLayoutDirection(direction);
+        mFlyout.setLayoutDirection(direction);
+        if (mStackEduView != null) {
+            mStackEduView.setLayoutDirection(direction);
+        }
+        if (mManageEduView != null) {
+            mManageEduView.setLayoutDirection(direction);
+        }
+        updateExpandedViewDirection(direction);
+    }
+
+    /** Respond to the display size change by recalculating view size and location. */
+    public void onDisplaySizeChanged() {
+        updateOverflow();
+
+        Resources res = getContext().getResources();
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        mBubbleSize = getResources().getDimensionPixelSize(R.dimen.individual_bubble_size);
+        for (Bubble b : mBubbleData.getBubbles()) {
+            if (b.getIconView() == null) {
+                Log.d(TAG, "Display size changed. Icon null: " + b);
+                continue;
+            }
+            b.getIconView().setLayoutParams(new LayoutParams(mBubbleSize, mBubbleSize));
+        }
+        mExpandedAnimationController.updateResources();
+        mStackAnimationController.updateResources();
+        mDismissView.updateResources();
+        mMagneticTarget.setMagneticFieldRadiusPx(mBubbleSize * 2);
+    }
+
+    @Override
+    public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) {
+        inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
+
+        mTempRect.setEmpty();
+        getTouchableRegion(mTempRect);
+        if (mIsExpanded && mExpandedBubble != null
+                && mExpandedBubble.getExpandedView() != null
+                && mExpandedBubble.getExpandedView().getTaskView() != null) {
+            inoutInfo.touchableRegion.set(mTempRect);
+            mExpandedBubble.getExpandedView().getTaskView().getBoundsOnScreen(mTempRect);
+            inoutInfo.touchableRegion.op(mTempRect, Region.Op.DIFFERENCE);
+        } else {
+            inoutInfo.touchableRegion.set(mTempRect);
+        }
+    }
+
+    @Override
+    protected void onAttachedToWindow() {
+        super.onAttachedToWindow();
+        getViewTreeObserver().addOnComputeInternalInsetsListener(this);
+    }
+
+    @Override
+    protected void onDetachedFromWindow() {
+        super.onDetachedFromWindow();
+        getViewTreeObserver().removeOnPreDrawListener(mViewUpdater);
+        getViewTreeObserver().removeOnComputeInternalInsetsListener(this);
+        if (mBubbleOverflow != null && mBubbleOverflow.getExpandedView() != null) {
+            mBubbleOverflow.getExpandedView().cleanUpExpandedState();
+        }
+    }
+
+    @Override
+    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
+        super.onInitializeAccessibilityNodeInfoInternal(info);
+        setupLocalMenu(info);
+    }
+
+    void updateExpandedViewTheme() {
+        final List<Bubble> bubbles = mBubbleData.getBubbles();
+        if (bubbles.isEmpty()) {
+            return;
+        }
+        bubbles.forEach(bubble -> {
+            if (bubble.getExpandedView() != null) {
+                bubble.getExpandedView().applyThemeAttrs();
+            }
+        });
+    }
+
+    void updateExpandedViewDirection(int direction) {
+        final List<Bubble> bubbles = mBubbleData.getBubbles();
+        if (bubbles.isEmpty()) {
+            return;
+        }
+        bubbles.forEach(bubble -> {
+            if (bubble.getExpandedView() != null) {
+                bubble.getExpandedView().setLayoutDirection(direction);
+            }
+        });
+    }
+
+    void setupLocalMenu(AccessibilityNodeInfo info) {
+        Resources res = mContext.getResources();
+
+        // Custom local actions.
+        AccessibilityAction moveTopLeft = new AccessibilityAction(R.id.action_move_top_left,
+                res.getString(R.string.bubble_accessibility_action_move_top_left));
+        info.addAction(moveTopLeft);
+
+        AccessibilityAction moveTopRight = new AccessibilityAction(R.id.action_move_top_right,
+                res.getString(R.string.bubble_accessibility_action_move_top_right));
+        info.addAction(moveTopRight);
+
+        AccessibilityAction moveBottomLeft = new AccessibilityAction(R.id.action_move_bottom_left,
+                res.getString(R.string.bubble_accessibility_action_move_bottom_left));
+        info.addAction(moveBottomLeft);
+
+        AccessibilityAction moveBottomRight = new AccessibilityAction(R.id.action_move_bottom_right,
+                res.getString(R.string.bubble_accessibility_action_move_bottom_right));
+        info.addAction(moveBottomRight);
+
+        // Default actions.
+        info.addAction(AccessibilityAction.ACTION_DISMISS);
+        if (mIsExpanded) {
+            info.addAction(AccessibilityAction.ACTION_COLLAPSE);
+        } else {
+            info.addAction(AccessibilityAction.ACTION_EXPAND);
+        }
+    }
+
+    @Override
+    public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
+        if (super.performAccessibilityActionInternal(action, arguments)) {
+            return true;
+        }
+        final RectF stackBounds = mStackAnimationController.getAllowableStackPositionRegion();
+
+        // R constants are not final so we cannot use switch-case here.
+        if (action == AccessibilityNodeInfo.ACTION_DISMISS) {
+            mBubbleData.dismissAll(Bubbles.DISMISS_ACCESSIBILITY_ACTION);
+            announceForAccessibility(
+                    getResources().getString(R.string.accessibility_bubble_dismissed));
+            return true;
+        } else if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
+            mBubbleData.setExpanded(false);
+            return true;
+        } else if (action == AccessibilityNodeInfo.ACTION_EXPAND) {
+            mBubbleData.setExpanded(true);
+            return true;
+        } else if (action == R.id.action_move_top_left) {
+            mStackAnimationController.springStackAfterFling(stackBounds.left, stackBounds.top);
+            return true;
+        } else if (action == R.id.action_move_top_right) {
+            mStackAnimationController.springStackAfterFling(stackBounds.right, stackBounds.top);
+            return true;
+        } else if (action == R.id.action_move_bottom_left) {
+            mStackAnimationController.springStackAfterFling(stackBounds.left, stackBounds.bottom);
+            return true;
+        } else if (action == R.id.action_move_bottom_right) {
+            mStackAnimationController.springStackAfterFling(stackBounds.right, stackBounds.bottom);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Update content description for a11y TalkBack.
+     */
+    public void updateContentDescription() {
+        if (mBubbleData.getBubbles().isEmpty()) {
+            return;
+        }
+
+        for (int i = 0; i < mBubbleData.getBubbles().size(); i++) {
+            final Bubble bubble = mBubbleData.getBubbles().get(i);
+            final String appName = bubble.getAppName();
+
+            String titleStr = bubble.getTitle();
+            if (titleStr == null) {
+                titleStr = getResources().getString(R.string.notification_bubble_title);
+            }
+
+            if (bubble.getIconView() != null) {
+                if (mIsExpanded || i > 0) {
+                    bubble.getIconView().setContentDescription(getResources().getString(
+                            R.string.bubble_content_description_single, titleStr, appName));
+                } else {
+                    final int moreCount = mBubbleContainer.getChildCount() - 1;
+                    bubble.getIconView().setContentDescription(getResources().getString(
+                            R.string.bubble_content_description_stack,
+                            titleStr, appName, moreCount));
+                }
+            }
+        }
+    }
+
+    private void updateSystemGestureExcludeRects() {
+        // Exclude the region occupied by the first BubbleView in the stack
+        Rect excludeZone = mSystemGestureExclusionRects.get(0);
+        if (getBubbleCount() > 0) {
+            View firstBubble = mBubbleContainer.getChildAt(0);
+            excludeZone.set(firstBubble.getLeft(), firstBubble.getTop(), firstBubble.getRight(),
+                    firstBubble.getBottom());
+            excludeZone.offset((int) (firstBubble.getTranslationX() + 0.5f),
+                    (int) (firstBubble.getTranslationY() + 0.5f));
+            mBubbleContainer.setSystemGestureExclusionRects(mSystemGestureExclusionRects);
+        } else {
+            excludeZone.setEmpty();
+            mBubbleContainer.setSystemGestureExclusionRects(Collections.emptyList());
+        }
+    }
+
+    /**
+     * Sets the listener to notify when the bubble stack is expanded.
+     */
+    public void setExpandListener(BubbleController.BubbleExpandListener listener) {
+        mExpandListener = listener;
+    }
+
+    /** Sets the function to call to un-bubble the given conversation. */
+    public void setUnbubbleConversationCallback(
+            Consumer<String> unbubbleConversationCallback) {
+        mUnbubbleConversationCallback = unbubbleConversationCallback;
+    }
+
+    /**
+     * Whether the stack of bubbles is expanded or not.
+     */
+    public boolean isExpanded() {
+        return mIsExpanded;
+    }
+
+    /**
+     * Whether the stack of bubbles is animating to or from expansion.
+     */
+    public boolean isExpansionAnimating() {
+        return mIsExpansionAnimating;
+    }
+
+    /**
+     * The {@link Bubble} that is expanded, null if one does not exist.
+     */
+    @VisibleForTesting
+    @Nullable
+    public BubbleViewProvider getExpandedBubble() {
+        return mExpandedBubble;
+    }
+
+    // via BubbleData.Listener
+    @SuppressLint("ClickableViewAccessibility")
+    void addBubble(Bubble bubble) {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "addBubble: " + bubble);
+        }
+
+        if (getBubbleCount() == 0 && shouldShowStackEdu()) {
+            // Override the default stack position if we're showing user education.
+            mStackAnimationController.setStackPosition(
+                    mStackAnimationController.getStartPosition());
+        }
+
+        if (getBubbleCount() == 0) {
+            mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide();
+        }
+
+        if (bubble.getIconView() == null) {
+            return;
+        }
+
+        // Set the dot position to the opposite of the side the stack is resting on, since the stack
+        // resting slightly off-screen would result in the dot also being off-screen.
+        bubble.getIconView().setDotBadgeOnLeft(!mStackOnLeftOrWillBe /* onLeft */);
+
+        bubble.getIconView().setOnClickListener(mBubbleClickListener);
+        bubble.getIconView().setOnTouchListener(mBubbleTouchListener);
+
+        mBubbleContainer.addView(bubble.getIconView(), 0,
+                new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+        animateInFlyoutForBubble(bubble);
+        requestUpdate();
+        logBubbleEvent(bubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
+    }
+
+    // via BubbleData.Listener
+    void removeBubble(Bubble bubble) {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "removeBubble: " + bubble);
+        }
+        // Remove it from the views
+        for (int i = 0; i < getBubbleCount(); i++) {
+            View v = mBubbleContainer.getChildAt(i);
+            if (v instanceof BadgedImageView
+                    && ((BadgedImageView) v).getKey().equals(bubble.getKey())) {
+                mBubbleContainer.removeViewAt(i);
+                if (mBubbleData.hasOverflowBubbleWithKey(bubble.getKey())) {
+                    bubble.cleanupExpandedView();
+                } else {
+                    bubble.cleanupViews();
+                }
+                updatePointerPosition();
+                logBubbleEvent(bubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
+                return;
+            }
+        }
+        Log.d(TAG, "was asked to remove Bubble, but didn't find the view! " + bubble);
+    }
+
+    private void updateOverflowVisibility() {
+        if (mBubbleOverflow == null) {
+            return;
+        }
+        mBubbleOverflow.setVisible(mIsExpanded ? VISIBLE : GONE);
+    }
+
+    // via BubbleData.Listener
+    void updateBubble(Bubble bubble) {
+        animateInFlyoutForBubble(bubble);
+        requestUpdate();
+        logBubbleEvent(bubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__UPDATED);
+    }
+
+    /**
+     * Update bubble order and pointer position.
+     */
+    public void updateBubbleOrder(List<Bubble> bubbles) {
+        final Runnable reorder = () -> {
+            for (int i = 0; i < bubbles.size(); i++) {
+                Bubble bubble = bubbles.get(i);
+                mBubbleContainer.reorderView(bubble.getIconView(), i);
+            }
+        };
+        if (mIsExpanded) {
+            reorder.run();
+            updateBubbleIcons();
+        } else {
+            List<View> bubbleViews = bubbles.stream()
+                    .map(b -> b.getIconView()).collect(Collectors.toList());
+            mStackAnimationController.animateReorder(bubbleViews, reorder);
+        }
+        updatePointerPosition();
+    }
+
+    /**
+     * Changes the currently selected bubble. If the stack is already expanded, the newly selected
+     * bubble will be shown immediately. This does not change the expanded state or change the
+     * position of any bubble.
+     */
+    // via BubbleData.Listener
+    public void setSelectedBubble(@Nullable BubbleViewProvider bubbleToSelect) {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "setSelectedBubble: " + bubbleToSelect);
+        }
+
+        if (bubbleToSelect == null) {
+            mBubbleData.setShowingOverflow(false);
+            return;
+        }
+
+        // Ignore this new bubble only if it is the exact same bubble object. Otherwise, we'll want
+        // to re-render it even if it has the same key (equals() returns true). If the currently
+        // expanded bubble is removed and instantly re-added, we'll get back a new Bubble instance
+        // with the same key (with newly inflated expanded views), and we need to render those new
+        // views.
+        if (mExpandedBubble == bubbleToSelect) {
+            return;
+        }
+
+        if (bubbleToSelect.getKey().equals(BubbleOverflow.KEY)) {
+            mBubbleData.setShowingOverflow(true);
+        } else {
+            mBubbleData.setShowingOverflow(false);
+        }
+
+        if (mIsExpanded && mIsExpansionAnimating) {
+            // If the bubble selection changed during the expansion animation, the expanding bubble
+            // probably crashed or immediately removed itself (or, we just got unlucky with a new
+            // auto-expanding bubble showing up at just the right time). Cancel the animations so we
+            // can start fresh.
+            cancelAllExpandCollapseSwitchAnimations();
+        }
+
+        // If we're expanded, screenshot the currently expanded bubble (before expanding the newly
+        // selected bubble) so we can animate it out.
+        if (mIsExpanded && mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+            if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+                // Before screenshotting, have the real ActivityView show on top of other surfaces
+                // so that the screenshot doesn't flicker on top of it.
+                mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(true);
+            }
+
+            try {
+                screenshotAnimatingOutBubbleIntoSurface((success) -> {
+                    mAnimatingOutSurfaceContainer.setVisibility(
+                            success ? View.VISIBLE : View.INVISIBLE);
+                    showNewlySelectedBubble(bubbleToSelect);
+                });
+            } catch (Exception e) {
+                showNewlySelectedBubble(bubbleToSelect);
+                e.printStackTrace();
+            }
+        } else {
+            showNewlySelectedBubble(bubbleToSelect);
+        }
+    }
+
+    private void showNewlySelectedBubble(BubbleViewProvider bubbleToSelect) {
+        final BubbleViewProvider previouslySelected = mExpandedBubble;
+        mExpandedBubble = bubbleToSelect;
+        updatePointerPosition();
+
+        if (mIsExpanded) {
+            hideCurrentInputMethod();
+
+            // Make the container of the expanded view transparent before removing the expanded view
+            // from it. Otherwise a punch hole created by {@link android.view.SurfaceView} in the
+            // expanded view becomes visible on the screen. See b/126856255
+            mExpandedViewContainer.setAlpha(0.0f);
+            mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
+                if (previouslySelected != null) {
+                    previouslySelected.setContentVisibility(false);
+                }
+
+                updateExpandedBubble();
+                requestUpdate();
+
+                logBubbleEvent(previouslySelected,
+                        FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
+                logBubbleEvent(bubbleToSelect,
+                        FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
+                notifyExpansionChanged(previouslySelected, false /* expanded */);
+                notifyExpansionChanged(bubbleToSelect, true /* expanded */);
+            });
+        }
+    }
+
+    /**
+     * Changes the expanded state of the stack.
+     *
+     * @param shouldExpand whether the bubble stack should appear expanded
+     */
+    // via BubbleData.Listener
+    public void setExpanded(boolean shouldExpand) {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "setExpanded: " + shouldExpand);
+        }
+
+        if (!shouldExpand) {
+            // If we're collapsing, release the animating-out surface immediately since we have no
+            // need for it, and this ensures it cannot remain visible as we collapse.
+            releaseAnimatingOutBubbleBuffer();
+        }
+
+        if (shouldExpand == mIsExpanded) {
+            return;
+        }
+
+        hideCurrentInputMethod();
+
+        mBubbleController.getSysuiProxy().onStackExpandChanged(shouldExpand);
+
+        if (mIsExpanded) {
+            animateCollapse();
+            logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
+        } else {
+            animateExpansion();
+            // TODO: move next line to BubbleData
+            logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
+            logBubbleEvent(mExpandedBubble,
+                    FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED);
+        }
+        notifyExpansionChanged(mExpandedBubble, mIsExpanded);
+    }
+
+    /**
+     * Asks the BubbleController to hide the IME from anywhere, whether it's focused on Bubbles or
+     * not.
+     */
+    void hideCurrentInputMethod() {
+        mBubbleController.hideCurrentInputMethod();
+    }
+
+    private void beforeExpandedViewAnimation() {
+        mIsExpansionAnimating = true;
+        hideFlyoutImmediate();
+        updateExpandedBubble();
+        updateExpandedView();
+    }
+
+    private void afterExpandedViewAnimation() {
+        mIsExpansionAnimating = false;
+        updateExpandedView();
+        requestUpdate();
+    }
+
+    private void animateExpansion() {
+        cancelDelayedExpandCollapseSwitchAnimations();
+        final boolean isLandscape =
+                mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE;
+        mIsExpanded = true;
+        if (mStackEduView != null) {
+            mStackEduView.hide(true /* fromExpansion */);
+        }
+        beforeExpandedViewAnimation();
+
+        mBubbleContainer.setActiveController(mExpandedAnimationController);
+        updateOverflowVisibility();
+        updatePointerPosition();
+        mExpandedAnimationController.expandFromStack(() -> {
+            if (mIsExpanded && mExpandedBubble.getExpandedView() != null) {
+                maybeShowManageEdu();
+            }
+        } /* after */);
+
+        mExpandedViewContainer.setTranslationX(0f);
+        mExpandedViewContainer.setTranslationY(getExpandedViewY());
+        mExpandedViewContainer.setAlpha(1f);
+
+        // X-value of the bubble we're expanding, once it's settled in its row.
+        final float bubbleWillBeAt =
+                mExpandedAnimationController.getBubbleXOrYForOrientation(
+                        mBubbleData.getBubbles().indexOf(mExpandedBubble));
+
+        // How far horizontally the bubble will be animating. We'll wait a bit longer for bubbles
+        // that are animating farther, so that the expanded view doesn't move as much.
+        final float relevantStackPosition = isLandscape
+                ? mStackAnimationController.getStackPosition().y
+                : mStackAnimationController.getStackPosition().x;
+        final float distanceAnimated = Math.abs(bubbleWillBeAt - relevantStackPosition);
+
+        // Wait for the path animation target to reach its end, and add a small amount of extra time
+        // if the bubble is moving a lot horizontally.
+        long startDelay = 0L;
+
+        // Should not happen since we lay out before expanding, but just in case...
+        if (getWidth() > 0) {
+            startDelay = (long)
+                    (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION
+                            + (distanceAnimated / getWidth()) * 30);
+        }
+
+        // Set the pivot point for the scale, so the expanded view animates out from the bubble.
+        if (isLandscape) {
+            float pivotX;
+            float pivotY = bubbleWillBeAt + mBubbleSize / 2f;
+            if (mStackOnLeftOrWillBe) {
+                pivotX = mPositioner.getAvailableRect().left + mBubbleSize + mExpandedViewPadding;
+            } else {
+                pivotX = mPositioner.getAvailableRect().right - mBubbleSize - mExpandedViewPadding;
+            }
+            mExpandedViewContainerMatrix.setScale(
+                    0f, 0f,
+                    pivotX, pivotY);
+        } else {
+            mExpandedViewContainerMatrix.setScale(
+                    0f, 0f,
+                    bubbleWillBeAt + mBubbleSize / 2f, getExpandedViewY());
+        }
+        mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+
+        if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+            mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
+        }
+
+        mDelayedAnimationHandler.postDelayed(() -> {
+            PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+            PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+                    .spring(AnimatableScaleMatrix.SCALE_X,
+                            AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+                            mScaleInSpringConfig)
+                    .spring(AnimatableScaleMatrix.SCALE_Y,
+                            AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+                            mScaleInSpringConfig)
+                    .addUpdateListener((target, values) -> {
+                        if (mExpandedBubble == null || mExpandedBubble.getIconView() == null) {
+                            return;
+                        }
+                        float translation = isLandscape
+                                ? mExpandedBubble.getIconView().getTranslationY()
+                                : mExpandedBubble.getIconView().getTranslationX();
+                        mExpandedViewContainerMatrix.postTranslate(
+                                translation - bubbleWillBeAt,
+                                0);
+                        mExpandedViewContainer.setAnimationMatrix(
+                                mExpandedViewContainerMatrix);
+                    })
+                    .withEndActions(() -> {
+                        afterExpandedViewAnimation();
+                        if (mExpandedBubble != null
+                                && mExpandedBubble.getExpandedView() != null) {
+                            mExpandedBubble.getExpandedView()
+                                    .setSurfaceZOrderedOnTop(false);
+                        }
+                    })
+                    .start();
+        }, startDelay);
+    }
+
+    private void animateCollapse() {
+        cancelDelayedExpandCollapseSwitchAnimations();
+
+        // Hide the menu if it's visible.
+        showManageMenu(false);
+
+        mIsExpanded = false;
+        mIsExpansionAnimating = true;
+
+        mBubbleContainer.cancelAllAnimations();
+
+        // If we were in the middle of swapping, the animating-out surface would have been scaling
+        // to zero - finish it off.
+        PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+        mAnimatingOutSurfaceContainer.setScaleX(0f);
+        mAnimatingOutSurfaceContainer.setScaleY(0f);
+
+        // Let the expanded animation controller know that it shouldn't animate child adds/reorders
+        // since we're about to animate collapsed.
+        mExpandedAnimationController.notifyPreparingToCollapse();
+
+        final long startDelay =
+                (long) (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION * 0.6f);
+        mDelayedAnimationHandler.postDelayed(() -> mExpandedAnimationController.collapseBackToStack(
+                mStackAnimationController.getStackPositionAlongNearestHorizontalEdge()
+                /* collapseTo */,
+                () -> mBubbleContainer.setActiveController(mStackAnimationController)), startDelay);
+
+        // We want to visually collapse into this bubble during the animation.
+        final View expandingFromBubble = mExpandedBubble.getIconView();
+
+        // Value the bubble is animating from (back into the stack).
+        final float expandingFromBubbleAt =
+                mExpandedAnimationController.getBubbleXOrYForOrientation(
+                        mBubbleData.getBubbles().indexOf(mExpandedBubble));
+        final boolean isLandscape =
+                mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE;
+        if (isLandscape) {
+            float pivotX;
+            float pivotY = expandingFromBubbleAt + mBubbleSize / 2f;
+            if (mStackOnLeftOrWillBe) {
+                pivotX = mPositioner.getAvailableRect().left + mBubbleSize + mExpandedViewPadding;
+            } else {
+                pivotX = mPositioner.getAvailableRect().right - mBubbleSize - mExpandedViewPadding;
+            }
+            mExpandedViewContainerMatrix.setScale(
+                    1f, 1f,
+                    pivotX, pivotY);
+        } else {
+            mExpandedViewContainerMatrix.setScale(
+                    1f, 1f,
+                    expandingFromBubbleAt + mBubbleSize / 2f,
+                    getExpandedViewY());
+        }
+
+        PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+        PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+                .spring(AnimatableScaleMatrix.SCALE_X, 0f, mScaleOutSpringConfig)
+                .spring(AnimatableScaleMatrix.SCALE_Y, 0f, mScaleOutSpringConfig)
+                .addUpdateListener((target, values) -> {
+                    if (expandingFromBubble != null) {
+                        // Follow the bubble as it translates!
+                        if (isLandscape) {
+                            mExpandedViewContainerMatrix.postTranslate(
+                                    0f, expandingFromBubble.getTranslationY()
+                                            - expandingFromBubbleAt);
+                        } else {
+                            mExpandedViewContainerMatrix.postTranslate(
+                                    expandingFromBubble.getTranslationX()
+                                            - expandingFromBubbleAt, 0f);
+                        }
+                    }
+
+                    mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+
+                    // Hide early so we don't have a tiny little expanded view still visible at the
+                    // end of the scale animation.
+                    if (mExpandedViewContainerMatrix.getScaleX() < 0.05f) {
+                        mExpandedViewContainer.setVisibility(View.INVISIBLE);
+                    }
+                })
+                .withEndActions(() -> {
+                    final BubbleViewProvider previouslySelected = mExpandedBubble;
+                    beforeExpandedViewAnimation();
+                    if (mManageEduView != null) {
+                        mManageEduView.hide(false /* fromExpansion */);
+                    }
+
+                    if (DEBUG_BUBBLE_STACK_VIEW) {
+                        Log.d(TAG, "animateCollapse");
+                        Log.d(TAG, BubbleDebugConfig.formatBubblesString(getBubblesOnScreen(),
+                                mExpandedBubble));
+                    }
+                    updateOverflowVisibility();
+
+                    afterExpandedViewAnimation();
+                    if (previouslySelected != null) {
+                        previouslySelected.setContentVisibility(false);
+                    }
+                })
+                .start();
+    }
+
+    private void animateSwitchBubbles() {
+        // If we're no longer expanded, this is meaningless.
+        if (!mIsExpanded) {
+            return;
+        }
+
+        mIsBubbleSwitchAnimating = true;
+
+        // The surface contains a screenshot of the animating out bubble, so we just need to animate
+        // it out (and then release the GraphicBuffer).
+        PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+        PhysicsAnimator animator = PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer)
+                .spring(DynamicAnimation.SCALE_X, 0f, mScaleOutSpringConfig)
+                .spring(DynamicAnimation.SCALE_Y, 0f, mScaleOutSpringConfig)
+                .withEndActions(this::releaseAnimatingOutBubbleBuffer);
+
+        if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+            float translationX = mStackAnimationController.isStackOnLeftSide()
+                    ? mAnimatingOutSurfaceContainer.getTranslationX() + mBubbleSize * 2
+                    : mAnimatingOutSurfaceContainer.getTranslationX();
+            animator.spring(DynamicAnimation.TRANSLATION_X,
+                    translationX,
+                    mTranslateSpringConfig)
+                    .start();
+        } else {
+            animator.spring(DynamicAnimation.TRANSLATION_Y,
+                    mAnimatingOutSurfaceContainer.getTranslationY() - mBubbleSize * 2,
+                    mTranslateSpringConfig)
+                    .start();
+        }
+
+        boolean isOverflow = mExpandedBubble != null
+                && mExpandedBubble.getKey().equals(BubbleOverflow.KEY);
+        float expandingFromBubbleDestination =
+                mExpandedAnimationController.getBubbleXOrYForOrientation(isOverflow
+                        ? getBubbleCount()
+                        : mBubbleData.getBubbles().indexOf(mExpandedBubble));
+
+        mExpandedViewContainer.setAlpha(1f);
+        mExpandedViewContainer.setVisibility(View.VISIBLE);
+
+        if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+            float pivotX;
+            float pivotY = expandingFromBubbleDestination + mBubbleSize / 2f;
+            if (mStackOnLeftOrWillBe) {
+                pivotX = mPositioner.getAvailableRect().left + mBubbleSize + mExpandedViewPadding;
+            } else {
+                pivotX = mPositioner.getAvailableRect().right - mBubbleSize - mExpandedViewPadding;
+
+            }
+            mExpandedViewContainerMatrix.setScale(
+                    0f, 0f,
+                    pivotX, pivotY);
+        } else {
+            mExpandedViewContainerMatrix.setScale(
+                    0f, 0f,
+                    expandingFromBubbleDestination + mBubbleSize / 2f,
+                    getExpandedViewY());
+        }
+
+        mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+
+        mDelayedAnimationHandler.postDelayed(() -> {
+            if (!mIsExpanded) {
+                mIsBubbleSwitchAnimating = false;
+                return;
+            }
+
+            PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+            PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+                    .spring(AnimatableScaleMatrix.SCALE_X,
+                            AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+                            mScaleInSpringConfig)
+                    .spring(AnimatableScaleMatrix.SCALE_Y,
+                            AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+                            mScaleInSpringConfig)
+                    .addUpdateListener((target, values) -> {
+                        mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+                    })
+                    .withEndActions(() -> {
+                        if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+                            mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
+                        }
+
+                        mIsBubbleSwitchAnimating = false;
+                    })
+                    .start();
+        }, 25);
+    }
+
+    /**
+     * Cancels any delayed steps for expand/collapse and bubble switch animations, and resets the is
+     * animating flags for those animations.
+     */
+    private void cancelDelayedExpandCollapseSwitchAnimations() {
+        mDelayedAnimationHandler.removeCallbacksAndMessages(null);
+
+        mIsExpansionAnimating = false;
+        mIsBubbleSwitchAnimating = false;
+    }
+
+    private void cancelAllExpandCollapseSwitchAnimations() {
+        cancelDelayedExpandCollapseSwitchAnimations();
+
+        PhysicsAnimator.getInstance(mAnimatingOutSurfaceView).cancel();
+        PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+
+        mExpandedViewContainer.setAnimationMatrix(null);
+    }
+
+    private void notifyExpansionChanged(BubbleViewProvider bubble, boolean expanded) {
+        if (mExpandListener != null && bubble != null) {
+            mExpandListener.onBubbleExpandChanged(expanded, bubble.getKey());
+        }
+    }
+
+    /** Moves the bubbles out of the way if they're going to be over the keyboard. */
+    public void onImeVisibilityChanged(boolean visible, int height) {
+        mStackAnimationController.setImeHeight(visible ? height + mImeOffset : 0);
+
+        if (!mIsExpanded && getBubbleCount() > 0) {
+            final float stackDestinationY =
+                    mStackAnimationController.animateForImeVisibility(visible);
+
+            // How far the stack is animating due to IME, we'll just animate the flyout by that
+            // much too.
+            final float stackDy =
+                    stackDestinationY - mStackAnimationController.getStackPosition().y;
+
+            // If the flyout is visible, translate it along with the bubble stack.
+            if (mFlyout.getVisibility() == VISIBLE) {
+                PhysicsAnimator.getInstance(mFlyout)
+                        .spring(DynamicAnimation.TRANSLATION_Y,
+                                mFlyout.getTranslationY() + stackDy,
+                                FLYOUT_IME_ANIMATION_SPRING_CONFIG)
+                        .start();
+            }
+        } else if (mIsExpanded && mExpandedBubble != null
+                && mExpandedBubble.getExpandedView() != null) {
+            mExpandedBubble.getExpandedView().setImeVisible(visible);
+        }
+    }
+
+    /**
+     * This method is called by {@link android.app.ActivityView} because the BubbleStackView has a
+     * higher Z-index than the ActivityView (so that dragged-out bubbles are visible over the AV).
+     * ActivityView is asking BubbleStackView to subtract the stack's bounds from the provided
+     * touchable region, so that the ActivityView doesn't consume events meant for the stack. Due to
+     * the special nature of ActivityView, it does not respect the standard
+     * {@link #dispatchTouchEvent} and {@link #onInterceptTouchEvent} methods typically used for
+     * this purpose.
+     *
+     * BubbleStackView is MATCH_PARENT, so that bubbles can be positioned via their translation
+     * properties for performance reasons. This means that the default implementation of this method
+     * subtracts the entirety of the screen from the ActivityView's touchable region, resulting in
+     * it not receiving any touch events. This was previously addressed by returning false in the
+     * stack's {@link View#canReceivePointerEvents()} method, but this precluded the use of any
+     * touch handlers in the stack or its child views.
+     *
+     * To support touch handlers, we're overriding this method to leave the ActivityView's touchable
+     * region alone. The only touchable part of the stack that can ever overlap the AV is a
+     * dragged-out bubble that is animating back into the row of bubbles. It's not worth continually
+     * updating the touchable region to allow users to grab a bubble while it completes its ~50ms
+     * animation back to the bubble row.
+     *
+     * NOTE: Any future additions to the stack that obscure the ActivityView region will need their
+     * bounds subtracted here in order to receive touch events.
+     */
+    @Override
+    public void subtractObscuredTouchableRegion(Region touchableRegion, View view) {
+        // If the notification shade is expanded, or the manage menu is open, or we are showing
+        // manage bubbles user education, we shouldn't let the ActivityView steal any touch events
+        // from any location.
+        if (!mIsExpanded
+                || mShowingManage
+                || (mManageEduView != null
+                    && mManageEduView.getVisibility() == VISIBLE)) {
+            touchableRegion.setEmpty();
+        }
+    }
+
+    /**
+     * If you're here because you're not receiving touch events on a view that is a descendant of
+     * BubbleStackView, and you think BSV is intercepting them - it's not! You need to subtract the
+     * bounds of the view in question in {@link #subtractObscuredTouchableRegion}. The ActivityView
+     * consumes all touch events within its bounds, even for views like the BubbleStackView that are
+     * above it. It ignores typical view touch handling methods like this one and
+     * dispatchTouchEvent.
+     */
+    @Override
+    public boolean onInterceptTouchEvent(MotionEvent ev) {
+        return super.onInterceptTouchEvent(ev);
+    }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent ev) {
+        if (ev.getAction() != MotionEvent.ACTION_DOWN && ev.getActionIndex() != mPointerIndexDown) {
+            // Ignore touches from additional pointer indices.
+            return false;
+        }
+
+        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
+            mPointerIndexDown = ev.getActionIndex();
+        } else if (ev.getAction() == MotionEvent.ACTION_UP
+                || ev.getAction() == MotionEvent.ACTION_CANCEL) {
+            mPointerIndexDown = -1;
+        }
+
+        boolean dispatched = super.dispatchTouchEvent(ev);
+
+        // If a new bubble arrives while the collapsed stack is being dragged, it will be positioned
+        // at the front of the stack (under the touch position). Subsequent ACTION_MOVE events will
+        // then be passed to the new bubble, which will not consume them since it hasn't received an
+        // ACTION_DOWN yet. Work around this by passing MotionEvents directly to the touch handler
+        // until the current gesture ends with an ACTION_UP event.
+        if (!dispatched && !mIsExpanded && mIsGestureInProgress) {
+            dispatched = mBubbleTouchListener.onTouch(this /* view */, ev);
+        }
+
+        mIsGestureInProgress =
+                ev.getAction() != MotionEvent.ACTION_UP
+                        && ev.getAction() != MotionEvent.ACTION_CANCEL;
+
+        return dispatched;
+    }
+
+    void setFlyoutStateForDragLength(float deltaX) {
+        // This shouldn't happen, but if it does, just wait until the flyout lays out. This method
+        // is continually called.
+        if (mFlyout.getWidth() <= 0) {
+            return;
+        }
+
+        final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+        mFlyoutDragDeltaX = deltaX;
+
+        final float collapsePercent =
+                onLeft ? -deltaX / mFlyout.getWidth() : deltaX / mFlyout.getWidth();
+        mFlyout.setCollapsePercent(Math.min(1f, Math.max(0f, collapsePercent)));
+
+        // Calculate how to translate the flyout if it has been dragged too far in either direction.
+        float overscrollTranslation = 0f;
+        if (collapsePercent < 0f || collapsePercent > 1f) {
+            // Whether we are more than 100% transitioned to the dot.
+            final boolean overscrollingPastDot = collapsePercent > 1f;
+
+            // Whether we are overscrolling physically to the left - this can either be pulling the
+            // flyout away from the stack (if the stack is on the right) or pushing it to the left
+            // after it has already become the dot.
+            final boolean overscrollingLeft =
+                    (onLeft && collapsePercent > 1f) || (!onLeft && collapsePercent < 0f);
+            overscrollTranslation =
+                    (overscrollingPastDot ? collapsePercent - 1f : collapsePercent * -1)
+                            * (overscrollingLeft ? -1 : 1)
+                            * (mFlyout.getWidth() / (FLYOUT_OVERSCROLL_ATTENUATION_FACTOR
+                            // Attenuate the smaller dot less than the larger flyout.
+                            / (overscrollingPastDot ? 2 : 1)));
+        }
+
+        mFlyout.setTranslationX(mFlyout.getRestingTranslationX() + overscrollTranslation);
+    }
+
+    /** Passes the MotionEvent to the magnetized object and returns true if it was consumed. */
+    private boolean passEventToMagnetizedObject(MotionEvent event) {
+        return mMagnetizedObject != null && mMagnetizedObject.maybeConsumeMotionEvent(event);
+    }
+
+    /**
+     * Dismisses the magnetized object - either an individual bubble, if we're expanded, or the
+     * stack, if we're collapsed.
+     */
+    private void dismissMagnetizedObject() {
+        if (mIsExpanded) {
+            final View draggedOutBubbleView = (View) mMagnetizedObject.getUnderlyingObject();
+            dismissBubbleIfExists(mBubbleData.getBubbleWithView(draggedOutBubbleView));
+        } else {
+            mBubbleData.dismissAll(Bubbles.DISMISS_USER_GESTURE);
+        }
+    }
+
+    private void dismissBubbleIfExists(@Nullable Bubble bubble) {
+        if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+            mBubbleData.dismissBubbleWithKey(bubble.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        }
+    }
+
+    /** Prepares and starts the desaturate/darken animation on the bubble stack. */
+    private void animateDesaturateAndDarken(View targetView, boolean desaturateAndDarken) {
+        mDesaturateAndDarkenTargetView = targetView;
+
+        if (mDesaturateAndDarkenTargetView == null) {
+            return;
+        }
+
+        if (desaturateAndDarken) {
+            // Use the animated paint for the bubbles.
+            mDesaturateAndDarkenTargetView.setLayerType(
+                    View.LAYER_TYPE_HARDWARE, mDesaturateAndDarkenPaint);
+            mDesaturateAndDarkenAnimator.removeAllListeners();
+            mDesaturateAndDarkenAnimator.start();
+        } else {
+            mDesaturateAndDarkenAnimator.removeAllListeners();
+            mDesaturateAndDarkenAnimator.addListener(new AnimatorListenerAdapter() {
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    super.onAnimationEnd(animation);
+                    // Stop using the animated paint.
+                    resetDesaturationAndDarken();
+                }
+            });
+            mDesaturateAndDarkenAnimator.reverse();
+        }
+    }
+
+    private void resetDesaturationAndDarken() {
+
+        mDesaturateAndDarkenAnimator.removeAllListeners();
+        mDesaturateAndDarkenAnimator.cancel();
+
+        if (mDesaturateAndDarkenTargetView != null) {
+            mDesaturateAndDarkenTargetView.setLayerType(View.LAYER_TYPE_NONE, null);
+            mDesaturateAndDarkenTargetView = null;
+        }
+    }
+
+    /** Animates the flyout collapsed (to dot), or the reverse, starting with the given velocity. */
+    private void animateFlyoutCollapsed(boolean collapsed, float velX) {
+        final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+        // If the flyout was tapped, we want a higher stiffness for the collapse animation so it's
+        // faster.
+        mFlyoutTransitionSpring.getSpring().setStiffness(
+                (mBubbleToExpandAfterFlyoutCollapse != null)
+                        ? SpringForce.STIFFNESS_MEDIUM
+                        : SpringForce.STIFFNESS_LOW);
+        mFlyoutTransitionSpring
+                .setStartValue(mFlyoutDragDeltaX)
+                .setStartVelocity(velX)
+                .animateToFinalPosition(collapsed
+                        ? (onLeft ? -mFlyout.getWidth() : mFlyout.getWidth())
+                        : 0f);
+    }
+
+    /**
+     * Calculates the y position of the expanded view when it is expanded.
+     */
+    float getExpandedViewY() {
+        final int top = mPositioner.getAvailableRect().top;
+        if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+            return top + mExpandedViewPadding;
+        } else {
+            return top + mBubbleSize + mBubblePaddingTop;
+        }
+    }
+
+    private boolean shouldShowFlyout(Bubble bubble) {
+        Bubble.FlyoutMessage flyoutMessage = bubble.getFlyoutMessage();
+        final BadgedImageView bubbleView = bubble.getIconView();
+        if (flyoutMessage == null
+                || flyoutMessage.message == null
+                || !bubble.showFlyout()
+                || (mStackEduView != null && mStackEduView.getVisibility() == VISIBLE)
+                || isExpanded()
+                || mIsExpansionAnimating
+                || mIsGestureInProgress
+                || mBubbleToExpandAfterFlyoutCollapse != null
+                || bubbleView == null) {
+            if (bubbleView != null && mFlyout.getVisibility() != VISIBLE) {
+                bubbleView.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
+            }
+            // Skip the message if none exists, we're expanded or animating expansion, or we're
+            // about to expand a bubble from the previous tapped flyout, or if bubble view is null.
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Animates in the flyout for the given bubble, if available, and then hides it after some time.
+     */
+    @VisibleForTesting
+    void animateInFlyoutForBubble(Bubble bubble) {
+        if (!shouldShowFlyout(bubble)) {
+            return;
+        }
+
+        mFlyoutDragDeltaX = 0f;
+        clearFlyoutOnHide();
+        mAfterFlyoutHidden = () -> {
+            // Null it out to ensure it runs once.
+            mAfterFlyoutHidden = null;
+
+            if (mBubbleToExpandAfterFlyoutCollapse != null) {
+                // User tapped on the flyout and we should expand
+                mBubbleData.setSelectedBubble(mBubbleToExpandAfterFlyoutCollapse);
+                mBubbleData.setExpanded(true);
+                mBubbleToExpandAfterFlyoutCollapse = null;
+            }
+
+            // Stop suppressing the dot now that the flyout has morphed into the dot.
+            if (bubble.getIconView() != null) {
+                bubble.getIconView().removeDotSuppressionFlag(
+                        BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
+            }
+            // Hide the stack after a delay, if needed.
+            updateTemporarilyInvisibleAnimation(false /* hideImmediately */);
+        };
+
+        // Suppress the dot when we are animating the flyout.
+        bubble.getIconView().addDotSuppressionFlag(
+                BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
+
+        // Start flyout expansion. Post in case layout isn't complete and getWidth returns 0.
+        post(() -> {
+            // An auto-expanding bubble could have been posted during the time it takes to
+            // layout.
+            if (isExpanded() || bubble.getIconView() == null) {
+                return;
+            }
+            final Runnable expandFlyoutAfterDelay = () -> {
+                mAnimateInFlyout = () -> {
+                    mFlyout.setVisibility(VISIBLE);
+                    updateTemporarilyInvisibleAnimation(false /* hideImmediately */);
+                    mFlyoutDragDeltaX =
+                            mStackAnimationController.isStackOnLeftSide()
+                                    ? -mFlyout.getWidth()
+                                    : mFlyout.getWidth();
+                    animateFlyoutCollapsed(false /* collapsed */, 0 /* velX */);
+                    mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
+                };
+                mFlyout.postDelayed(mAnimateInFlyout, 200);
+            };
+
+
+            if (mFlyout.getVisibility() == View.VISIBLE) {
+                mFlyout.animateUpdate(bubble.getFlyoutMessage(), getWidth(),
+                        mStackAnimationController.getStackPosition().y);
+            } else {
+                mFlyout.setVisibility(INVISIBLE);
+                mFlyout.setupFlyoutStartingAsDot(bubble.getFlyoutMessage(),
+                        mStackAnimationController.getStackPosition(), getWidth(),
+                        mStackAnimationController.isStackOnLeftSide(),
+                        bubble.getIconView().getDotColor() /* dotColor */,
+                        expandFlyoutAfterDelay /* onLayoutComplete */,
+                        mAfterFlyoutHidden,
+                        bubble.getIconView().getDotCenter(),
+                        !bubble.showDot());
+            }
+            mFlyout.bringToFront();
+        });
+        mFlyout.removeCallbacks(mHideFlyout);
+        mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
+        logBubbleEvent(bubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__FLYOUT);
+    }
+
+    /** Hide the flyout immediately and cancel any pending hide runnables. */
+    private void hideFlyoutImmediate() {
+        clearFlyoutOnHide();
+        mFlyout.removeCallbacks(mAnimateInFlyout);
+        mFlyout.removeCallbacks(mHideFlyout);
+        mFlyout.hideFlyout();
+    }
+
+    private void clearFlyoutOnHide() {
+        mFlyout.removeCallbacks(mAnimateInFlyout);
+        if (mAfterFlyoutHidden == null) {
+            return;
+        }
+        mAfterFlyoutHidden.run();
+        mAfterFlyoutHidden = null;
+    }
+
+    /**
+     * Fills the Rect with the touchable region of the bubbles. This will be used by WindowManager
+     * to decide which touch events go to Bubbles.
+     *
+     * Bubbles is below the status bar/notification shade but above application windows. If you're
+     * trying to get touch events from the status bar or another higher-level window layer, you'll
+     * need to re-order TYPE_BUBBLES in WindowManagerPolicy so that we have the opportunity to steal
+     * them.
+     */
+    public void getTouchableRegion(Rect outRect) {
+        if (mStackEduView != null && mStackEduView.getVisibility() == VISIBLE) {
+            // When user education shows then capture all touches
+            outRect.set(0, 0, getWidth(), getHeight());
+            return;
+        }
+
+        if (!mIsExpanded) {
+            if (getBubbleCount() > 0) {
+                mBubbleContainer.getChildAt(0).getBoundsOnScreen(outRect);
+                // Increase the touch target size of the bubble
+                outRect.top -= mBubbleTouchPadding;
+                outRect.left -= mBubbleTouchPadding;
+                outRect.right += mBubbleTouchPadding;
+                outRect.bottom += mBubbleTouchPadding;
+            }
+        } else {
+            mBubbleContainer.getBoundsOnScreen(outRect);
+        }
+
+        if (mFlyout.getVisibility() == View.VISIBLE) {
+            final Rect flyoutBounds = new Rect();
+            mFlyout.getBoundsOnScreen(flyoutBounds);
+            outRect.union(flyoutBounds);
+        }
+    }
+
+    private void requestUpdate() {
+        if (mViewUpdatedRequested || mIsExpansionAnimating) {
+            return;
+        }
+        mViewUpdatedRequested = true;
+        getViewTreeObserver().addOnPreDrawListener(mViewUpdater);
+        invalidate();
+    }
+
+    private void showManageMenu(boolean show) {
+        mShowingManage = show;
+
+        // This should not happen, since the manage menu is only visible when there's an expanded
+        // bubble. If we end up in this state, just hide the menu immediately.
+        if (mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) {
+            mManageMenu.setVisibility(View.INVISIBLE);
+            return;
+        }
+
+        // If available, update the manage menu's settings option with the expanded bubble's app
+        // name and icon.
+        if (show && mBubbleData.hasBubbleInStackWithKey(mExpandedBubble.getKey())) {
+            final Bubble bubble = mBubbleData.getBubbleInStackWithKey(mExpandedBubble.getKey());
+            mManageSettingsIcon.setImageDrawable(bubble.getAppBadge());
+            mManageSettingsText.setText(getResources().getString(
+                    R.string.bubbles_app_settings, bubble.getAppName()));
+        }
+
+        mExpandedBubble.getExpandedView().getManageButtonBoundsOnScreen(mTempRect);
+
+        final boolean isLtr =
+                getResources().getConfiguration().getLayoutDirection() == LAYOUT_DIRECTION_LTR;
+
+        // When the menu is open, it should be at these coordinates. The menu pops out to the right
+        // in LTR and to the left in RTL.
+        final float targetX = isLtr ? mTempRect.left : mTempRect.right - mManageMenu.getWidth();
+        final float targetY = mTempRect.bottom - mManageMenu.getHeight();
+
+        final float xOffsetForAnimation = (isLtr ? 1 : -1) * mManageMenu.getWidth() / 4f;
+        if (show) {
+            mManageMenu.setScaleX(0.5f);
+            mManageMenu.setScaleY(0.5f);
+            mManageMenu.setTranslationX(targetX - xOffsetForAnimation);
+            mManageMenu.setTranslationY(targetY + mManageMenu.getHeight() / 4f);
+            mManageMenu.setAlpha(0f);
+
+            PhysicsAnimator.getInstance(mManageMenu)
+                    .spring(DynamicAnimation.ALPHA, 1f)
+                    .spring(DynamicAnimation.SCALE_X, 1f)
+                    .spring(DynamicAnimation.SCALE_Y, 1f)
+                    .spring(DynamicAnimation.TRANSLATION_X, targetX)
+                    .spring(DynamicAnimation.TRANSLATION_Y, targetY)
+                    .withEndActions(() -> {
+                        View child = mManageMenu.getChildAt(0);
+                        child.requestAccessibilityFocus();
+                        // Update the AV's obscured touchable region for the new visibility state.
+                        mExpandedBubble.getExpandedView().updateObscuredTouchableRegion();
+                    })
+                    .start();
+
+            mManageMenu.setVisibility(View.VISIBLE);
+        } else {
+            PhysicsAnimator.getInstance(mManageMenu)
+                    .spring(DynamicAnimation.ALPHA, 0f)
+                    .spring(DynamicAnimation.SCALE_X, 0.5f)
+                    .spring(DynamicAnimation.SCALE_Y, 0.5f)
+                    .spring(DynamicAnimation.TRANSLATION_X, targetX - xOffsetForAnimation)
+                    .spring(DynamicAnimation.TRANSLATION_Y, targetY + mManageMenu.getHeight() / 4f)
+                    .withEndActions(() -> {
+                        mManageMenu.setVisibility(View.INVISIBLE);
+                        if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+                            // Update the AV's obscured touchable region for the new state.
+                            mExpandedBubble.getExpandedView().updateObscuredTouchableRegion();
+                        }
+                    })
+                    .start();
+        }
+    }
+
+    private void updateExpandedBubble() {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "updateExpandedBubble()");
+        }
+
+        mExpandedViewContainer.removeAllViews();
+        if (mIsExpanded && mExpandedBubble != null
+                && mExpandedBubble.getExpandedView() != null) {
+            BubbleExpandedView bev = mExpandedBubble.getExpandedView();
+            bev.setContentVisibility(false);
+            mExpandedViewContainerMatrix.setScaleX(0f);
+            mExpandedViewContainerMatrix.setScaleY(0f);
+            mExpandedViewContainerMatrix.setTranslate(0f, 0f);
+            mExpandedViewContainer.setVisibility(View.INVISIBLE);
+            mExpandedViewContainer.setAlpha(0f);
+            mExpandedViewContainer.addView(bev);
+            bev.setManageClickListener((view) -> showManageMenu(!mShowingManage));
+
+            if (!mIsExpansionAnimating) {
+                mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
+                    post(this::animateSwitchBubbles);
+                });
+            }
+        }
+    }
+
+    /**
+     * Requests a snapshot from the currently expanded bubble's ActivityView and displays it in a
+     * SurfaceView. This allows us to load a newly expanded bubble's Activity into the ActivityView,
+     * while animating the (screenshot of the) previously selected bubble's content away.
+     *
+     * @param onComplete Callback to run once we're done here - called with 'false' if something
+     *                   went wrong, or 'true' if the SurfaceView is now showing a screenshot of the
+     *                   expanded bubble.
+     */
+    private void screenshotAnimatingOutBubbleIntoSurface(Consumer<Boolean> onComplete) {
+        if (!mIsExpanded || mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) {
+            // You can't animate null.
+            onComplete.accept(false);
+            return;
+        }
+
+        final BubbleExpandedView animatingOutExpandedView = mExpandedBubble.getExpandedView();
+
+        // Release the previous screenshot if it hasn't been released already.
+        if (mAnimatingOutBubbleBuffer != null) {
+            releaseAnimatingOutBubbleBuffer();
+        }
+
+        try {
+            mAnimatingOutBubbleBuffer = animatingOutExpandedView.snapshotActivitySurface();
+        } catch (Exception e) {
+            // If we fail for any reason, print the stack trace and then notify the callback of our
+            // failure. This is not expected to occur, but it's not worth crashing over.
+            Log.wtf(TAG, e);
+            onComplete.accept(false);
+        }
+
+        if (mAnimatingOutBubbleBuffer == null
+                || mAnimatingOutBubbleBuffer.getHardwareBuffer() == null) {
+            // While no exception was thrown, we were unable to get a snapshot.
+            onComplete.accept(false);
+            return;
+        }
+
+        // Make sure the surface container's properties have been reset.
+        PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+        mAnimatingOutSurfaceContainer.setScaleX(1f);
+        mAnimatingOutSurfaceContainer.setScaleY(1f);
+        mAnimatingOutSurfaceContainer.setTranslationX(mExpandedViewContainer.getPaddingLeft());
+        mAnimatingOutSurfaceContainer.setTranslationY(0);
+
+        final int[] activityViewLocation =
+                mExpandedBubble.getExpandedView().getTaskViewLocationOnScreen();
+        final int[] surfaceViewLocation = mAnimatingOutSurfaceView.getLocationOnScreen();
+
+        // Translate the surface to overlap the real ActivityView.
+        mAnimatingOutSurfaceContainer.setTranslationY(
+                activityViewLocation[1] - surfaceViewLocation[1]);
+
+        // Set the width/height of the SurfaceView to match the snapshot.
+        mAnimatingOutSurfaceView.getLayoutParams().width =
+                mAnimatingOutBubbleBuffer.getHardwareBuffer().getWidth();
+        mAnimatingOutSurfaceView.getLayoutParams().height =
+                mAnimatingOutBubbleBuffer.getHardwareBuffer().getHeight();
+        mAnimatingOutSurfaceView.requestLayout();
+
+        // Post to wait for layout.
+        post(() -> {
+            // The buffer might have been destroyed if the user is mashing on bubbles, that's okay.
+            if (mAnimatingOutBubbleBuffer == null
+                    || mAnimatingOutBubbleBuffer.getHardwareBuffer() == null
+                    || mAnimatingOutBubbleBuffer.getHardwareBuffer().isClosed()) {
+                onComplete.accept(false);
+                return;
+            }
+
+            if (!mIsExpanded) {
+                onComplete.accept(false);
+                return;
+            }
+
+            // Attach the buffer! We're now displaying the snapshot.
+            mAnimatingOutSurfaceView.getHolder().getSurface().attachAndQueueBufferWithColorSpace(
+                    mAnimatingOutBubbleBuffer.getHardwareBuffer(),
+                    mAnimatingOutBubbleBuffer.getColorSpace());
+
+            mSurfaceSynchronizer.syncSurfaceAndRun(() -> post(() -> onComplete.accept(true)));
+        });
+    }
+
+    /**
+     * Releases the buffer containing the screenshot of the animating-out bubble, if it exists and
+     * isn't yet destroyed.
+     */
+    private void releaseAnimatingOutBubbleBuffer() {
+        if (mAnimatingOutBubbleBuffer != null
+                && !mAnimatingOutBubbleBuffer.getHardwareBuffer().isClosed()) {
+            mAnimatingOutBubbleBuffer.getHardwareBuffer().close();
+        }
+    }
+
+    private void updateExpandedView() {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "updateExpandedView: mIsExpanded=" + mIsExpanded);
+        }
+
+        // Need to update the padding around the view for any insets
+        Insets insets = mPositioner.getInsets();
+        int leftPadding = insets.left + mExpandedViewPadding;
+        int rightPadding = insets.right + mExpandedViewPadding;
+        if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+            if (!mStackAnimationController.isStackOnLeftSide()) {
+                rightPadding += mPointerHeight + mBubbleSize;
+            } else {
+                leftPadding += mPointerHeight + mBubbleSize;
+            }
+        }
+        mExpandedViewContainer.setPadding(leftPadding, 0, rightPadding, 0);
+        mExpandedViewContainer.setVisibility(mIsExpanded ? VISIBLE : GONE);
+        if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+            mExpandedViewContainer.setTranslationY(getExpandedViewY());
+            mExpandedViewContainer.setTranslationX(0f);
+            mExpandedBubble.getExpandedView().updateView(
+                    mExpandedViewContainer.getLocationOnScreen());
+        }
+
+        mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide();
+        updateBubbleIcons();
+    }
+
+    /**
+     * Sets the appropriate Z-order, badge, and dot position for each bubble in the stack.
+     * Animate dot and badge changes.
+     */
+    private void updateBubbleIcons() {
+        int bubbleCount = getBubbleCount();
+        for (int i = 0; i < bubbleCount; i++) {
+            BadgedImageView bv = (BadgedImageView) mBubbleContainer.getChildAt(i);
+            bv.setZ((mMaxBubbles * mBubbleElevation) - i);
+
+            if (mIsExpanded) {
+                bv.showDotAndBadge(false /* onLeft */);
+            } else if (i == 0) {
+                bv.showDotAndBadge(!mStackOnLeftOrWillBe);
+            } else {
+                bv.hideDotAndBadge(!mStackOnLeftOrWillBe);
+            }
+        }
+    }
+
+    private void updatePointerPosition() {
+        if (mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) {
+            return;
+        }
+        int index = getBubbleIndex(mExpandedBubble);
+        if (index == -1) {
+            return;
+        }
+        float bubblePosition = mExpandedAnimationController.getBubbleXOrYForOrientation(index);
+        if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+            float x = mStackOnLeftOrWillBe
+                    ? mPositioner.getAvailableRect().left
+                    : mPositioner.getAvailableRect().right
+                            - mExpandedViewContainer.getPaddingRight()
+                            - mPointerHeight;
+            float bubbleCenter = bubblePosition - getExpandedViewY() + (mBubbleSize / 2f);
+            mExpandedBubble.getExpandedView().setPointerPosition(
+                    x,
+                    bubbleCenter,
+                    true,
+                    mStackOnLeftOrWillBe);
+        } else {
+            float bubbleCenter = bubblePosition + (mBubbleSize / 2f);
+            mExpandedBubble.getExpandedView().setPointerPosition(
+                    bubbleCenter,
+                    getExpandedViewY(),
+                    false,
+                    mStackOnLeftOrWillBe);
+        }
+    }
+
+    /**
+     * @return the number of bubbles in the stack view.
+     */
+    public int getBubbleCount() {
+        // Subtract 1 for the overflow button that is always in the bubble container.
+        return mBubbleContainer.getChildCount() - 1;
+    }
+
+    /**
+     * Finds the bubble index within the stack.
+     *
+     * @param provider the bubble view provider with the bubble to look up.
+     * @return the index of the bubble view within the bubble stack. The range of the position
+     * is between 0 and the bubble count minus 1.
+     */
+    int getBubbleIndex(@Nullable BubbleViewProvider provider) {
+        if (provider == null) {
+            return 0;
+        }
+        return mBubbleContainer.indexOfChild(provider.getIconView());
+    }
+
+    /**
+     * @return the normalized x-axis position of the bubble stack rounded to 4 decimal places.
+     */
+    public float getNormalizedXPosition() {
+        return new BigDecimal(getStackPosition().x / mPositioner.getAvailableRect().width())
+                .setScale(4, RoundingMode.CEILING.HALF_UP)
+                .floatValue();
+    }
+
+    /**
+     * @return the normalized y-axis position of the bubble stack rounded to 4 decimal places.
+     */
+    public float getNormalizedYPosition() {
+        return new BigDecimal(getStackPosition().y / mPositioner.getAvailableRect().height())
+                .setScale(4, RoundingMode.CEILING.HALF_UP)
+                .floatValue();
+    }
+
+    /** Set the start position of the bubble stack. */
+    public void setStackStartPosition(RelativeStackPosition position) {
+        mStackAnimationController.setStackStartPosition(position);
+    }
+
+    /** @return the position of the bubble stack. */
+    public PointF getStackPosition() {
+        return mStackAnimationController.getStackPosition();
+    }
+
+    /** @return the relative position of the bubble stack. */
+    public RelativeStackPosition getRelativeStackPosition() {
+        return mStackAnimationController.getRelativeStackPosition();
+    }
+
+    /**
+     * Logs the bubble UI event.
+     *
+     * @param provider the bubble view provider that is being interacted on. Null value indicates
+     *               that the user interaction is not specific to one bubble.
+     * @param action the user interaction enum.
+     */
+    private void logBubbleEvent(@Nullable BubbleViewProvider provider, int action) {
+        mBubbleData.logBubbleEvent(provider,
+                action,
+                mContext.getApplicationInfo().packageName,
+                getBubbleCount(),
+                getBubbleIndex(provider),
+                getNormalizedXPosition(),
+                getNormalizedYPosition());
+    }
+
+    /** For debugging only */
+    List<Bubble> getBubblesOnScreen() {
+        List<Bubble> bubbles = new ArrayList<>();
+        for (int i = 0; i < getBubbleCount(); i++) {
+            View child = mBubbleContainer.getChildAt(i);
+            if (child instanceof BadgedImageView) {
+                String key = ((BadgedImageView) child).getKey();
+                Bubble bubble = mBubbleData.getBubbleInStackWithKey(key);
+                bubbles.add(bubble);
+            }
+        }
+        return bubbles;
+    }
+
+    /**
+     * Representation of stack position that uses relative properties rather than absolute
+     * coordinates. This is used to maintain similar stack positions across configuration changes.
+     */
+    public static class RelativeStackPosition {
+        /** Whether to place the stack at the leftmost allowed position. */
+        private boolean mOnLeft;
+
+        /**
+         * How far down the vertically allowed region to place the stack. For example, if the stack
+         * allowed region is between y = 100 and y = 1100 and this is 0.2f, we'll place the stack at
+         * 100 + (0.2f * 1000) = 300.
+         */
+        private float mVerticalOffsetPercent;
+
+        public RelativeStackPosition(boolean onLeft, float verticalOffsetPercent) {
+            mOnLeft = onLeft;
+            mVerticalOffsetPercent = clampVerticalOffsetPercent(verticalOffsetPercent);
+        }
+
+        /** Constructs a relative position given a region and a point in that region. */
+        public RelativeStackPosition(PointF position, RectF region) {
+            mOnLeft = position.x < region.width() / 2;
+            mVerticalOffsetPercent =
+                    clampVerticalOffsetPercent((position.y - region.top) / region.height());
+        }
+
+        /** Ensures that the offset percent is between 0f and 1f. */
+        private float clampVerticalOffsetPercent(float offsetPercent) {
+            return Math.max(0f, Math.min(1f, offsetPercent));
+        }
+
+        /**
+         * Given an allowable stack position region, returns the point within that region
+         * represented by this relative position.
+         */
+        public PointF getAbsolutePositionInRegion(RectF region) {
+            return new PointF(
+                    mOnLeft ? region.left : region.right,
+                    region.top + mVerticalOffsetPercent * region.height());
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java
new file mode 100644
index 0000000..0b68306
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewInfoTask.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static com.android.wm.shell.bubbles.BadgedImageView.DEFAULT_PATH_SIZE;
+import static com.android.wm.shell.bubbles.BadgedImageView.WHITE_SCRIM_ALPHA;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.annotation.NonNull;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ShortcutInfo;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Path;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.os.AsyncTask;
+import android.util.Log;
+import android.util.PathParser;
+import android.view.LayoutInflater;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.graphics.ColorUtils;
+import com.android.launcher3.icons.BitmapInfo;
+import com.android.wm.shell.R;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+/**
+ * Simple task to inflate views & load necessary info to display a bubble.
+ */
+public class BubbleViewInfoTask extends AsyncTask<Void, Void, BubbleViewInfoTask.BubbleViewInfo> {
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleViewInfoTask" : TAG_BUBBLES;
+
+
+    /**
+     * Callback to find out when the bubble has been inflated & necessary data loaded.
+     */
+    public interface Callback {
+        /**
+         * Called when data has been loaded for the bubble.
+         */
+        void onBubbleViewsReady(Bubble bubble);
+    }
+
+    private Bubble mBubble;
+    private WeakReference<Context> mContext;
+    private WeakReference<BubbleController> mController;
+    private WeakReference<BubbleStackView> mStackView;
+    private BubbleIconFactory mIconFactory;
+    private boolean mSkipInflation;
+    private Callback mCallback;
+
+    /**
+     * Creates a task to load information for the provided {@link Bubble}. Once all info
+     * is loaded, {@link Callback} is notified.
+     */
+    BubbleViewInfoTask(Bubble b,
+            Context context,
+            BubbleController controller,
+            BubbleStackView stackView,
+            BubbleIconFactory factory,
+            boolean skipInflation,
+            Callback c) {
+        mBubble = b;
+        mContext = new WeakReference<>(context);
+        mController = new WeakReference<>(controller);
+        mStackView = new WeakReference<>(stackView);
+        mIconFactory = factory;
+        mSkipInflation = skipInflation;
+        mCallback = c;
+    }
+
+    @Override
+    protected BubbleViewInfo doInBackground(Void... voids) {
+        return BubbleViewInfo.populate(mContext.get(), mController.get(), mStackView.get(),
+                mIconFactory, mBubble, mSkipInflation);
+    }
+
+    @Override
+    protected void onPostExecute(BubbleViewInfo viewInfo) {
+        if (isCancelled() || viewInfo == null) {
+            return;
+        }
+        mBubble.setViewInfo(viewInfo);
+        if (mCallback != null) {
+            mCallback.onBubbleViewsReady(mBubble);
+        }
+    }
+
+    /**
+     * Info necessary to render a bubble.
+     */
+    static class BubbleViewInfo {
+        BadgedImageView imageView;
+        BubbleExpandedView expandedView;
+        ShortcutInfo shortcutInfo;
+        String appName;
+        Bitmap bubbleBitmap;
+        Drawable badgeDrawable;
+        int dotColor;
+        Path dotPath;
+        Bubble.FlyoutMessage flyoutMessage;
+
+        @Nullable
+        static BubbleViewInfo populate(Context c, BubbleController controller,
+                BubbleStackView stackView, BubbleIconFactory iconFactory, Bubble b,
+                boolean skipInflation) {
+            BubbleViewInfo info = new BubbleViewInfo();
+
+            // View inflation: only should do this once per bubble
+            if (!skipInflation && !b.isInflated()) {
+                LayoutInflater inflater = LayoutInflater.from(c);
+                info.imageView = (BadgedImageView) inflater.inflate(
+                        R.layout.bubble_view, stackView, false /* attachToRoot */);
+
+                info.expandedView = (BubbleExpandedView) inflater.inflate(
+                        R.layout.bubble_expanded_view, stackView, false /* attachToRoot */);
+                info.expandedView.initialize(controller, stackView);
+            }
+
+            if (b.getShortcutInfo() != null) {
+                info.shortcutInfo = b.getShortcutInfo();
+            }
+
+            // App name & app icon
+            PackageManager pm = c.getPackageManager();
+            ApplicationInfo appInfo;
+            Drawable badgedIcon;
+            Drawable appIcon;
+            try {
+                appInfo = pm.getApplicationInfo(
+                        b.getPackageName(),
+                        PackageManager.MATCH_UNINSTALLED_PACKAGES
+                                | PackageManager.MATCH_DISABLED_COMPONENTS
+                                | PackageManager.MATCH_DIRECT_BOOT_UNAWARE
+                                | PackageManager.MATCH_DIRECT_BOOT_AWARE);
+                if (appInfo != null) {
+                    info.appName = String.valueOf(pm.getApplicationLabel(appInfo));
+                }
+                appIcon = pm.getApplicationIcon(b.getPackageName());
+                badgedIcon = pm.getUserBadgedIcon(appIcon, b.getUser());
+            } catch (PackageManager.NameNotFoundException exception) {
+                // If we can't find package... don't think we should show the bubble.
+                Log.w(TAG, "Unable to find package: " + b.getPackageName());
+                return null;
+            }
+
+            // Badged bubble image
+            Drawable bubbleDrawable = iconFactory.getBubbleDrawable(c, info.shortcutInfo,
+                    b.getIcon());
+            if (bubbleDrawable == null) {
+                // Default to app icon
+                bubbleDrawable = appIcon;
+            }
+
+            BitmapInfo badgeBitmapInfo = iconFactory.getBadgeBitmap(badgedIcon,
+                    b.isImportantConversation());
+            info.badgeDrawable = badgedIcon;
+            info.bubbleBitmap = iconFactory.createBadgedIconBitmap(bubbleDrawable,
+                    null /* user */,
+                    true /* shrinkNonAdaptiveIcons */).icon;
+
+            // Dot color & placement
+            Path iconPath = PathParser.createPathFromPathData(
+                    c.getResources().getString(com.android.internal.R.string.config_icon_mask));
+            Matrix matrix = new Matrix();
+            float scale = iconFactory.getNormalizer().getScale(bubbleDrawable,
+                    null /* outBounds */, null /* path */, null /* outMaskShape */);
+            float radius = DEFAULT_PATH_SIZE / 2f;
+            matrix.setScale(scale /* x scale */, scale /* y scale */, radius /* pivot x */,
+                    radius /* pivot y */);
+            iconPath.transform(matrix);
+            info.dotPath = iconPath;
+            info.dotColor = ColorUtils.blendARGB(badgeBitmapInfo.color,
+                    Color.WHITE, WHITE_SCRIM_ALPHA);
+
+            // Flyout
+            info.flyoutMessage = b.getFlyoutMessage();
+            if (info.flyoutMessage != null) {
+                info.flyoutMessage.senderAvatar =
+                        loadSenderAvatar(c, info.flyoutMessage.senderIcon);
+            }
+            return info;
+        }
+    }
+
+    @Nullable
+    static Drawable loadSenderAvatar(@NonNull final Context context, @Nullable final Icon icon) {
+        Objects.requireNonNull(context);
+        if (icon == null) return null;
+        if (icon.getType() == Icon.TYPE_URI || icon.getType() == Icon.TYPE_URI_ADAPTIVE_BITMAP) {
+            context.grantUriPermission(context.getPackageName(),
+                    icon.getUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        }
+        return icon.loadDrawable(context);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewProvider.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewProvider.java
new file mode 100644
index 0000000..ec900be
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleViewProvider.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.graphics.Bitmap;
+import android.graphics.Path;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Interface to represent actual Bubbles and UI elements that act like bubbles, like BubbleOverflow.
+ */
+public interface BubbleViewProvider {
+    @Nullable BubbleExpandedView getExpandedView();
+
+    void setContentVisibility(boolean visible);
+
+    @Nullable View getIconView();
+
+    String getKey();
+
+    /** Bubble icon bitmap with no badge and no dot. */
+    Bitmap getBubbleIcon();
+
+    /** App badge drawable to draw above bubble icon. */
+    @Nullable Drawable getAppBadge();
+
+    /** Path of normalized bubble icon to draw dot on. */
+    Path getDotPath();
+
+    int getDotColor();
+
+    boolean showDot();
+
+    int getTaskId();
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubbles.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubbles.java
new file mode 100644
index 0000000..79c42d8
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubbles.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.content.res.Configuration;
+import android.service.notification.NotificationListenerService.RankingMap;
+import android.util.ArraySet;
+import android.view.View;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.List;
+import java.util.function.IntConsumer;
+
+/**
+ * Interface to engage bubbles feature.
+ */
+public interface Bubbles {
+
+    @Retention(SOURCE)
+    @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
+            DISMISS_NOTIF_CANCEL, DISMISS_ACCESSIBILITY_ACTION, DISMISS_NO_LONGER_BUBBLE,
+            DISMISS_USER_CHANGED, DISMISS_GROUP_CANCELLED, DISMISS_INVALID_INTENT,
+            DISMISS_OVERFLOW_MAX_REACHED, DISMISS_SHORTCUT_REMOVED, DISMISS_PACKAGE_REMOVED,
+            DISMISS_NO_BUBBLE_UP})
+    @Target({FIELD, LOCAL_VARIABLE, PARAMETER})
+    @interface DismissReason {}
+
+    int DISMISS_USER_GESTURE = 1;
+    int DISMISS_AGED = 2;
+    int DISMISS_TASK_FINISHED = 3;
+    int DISMISS_BLOCKED = 4;
+    int DISMISS_NOTIF_CANCEL = 5;
+    int DISMISS_ACCESSIBILITY_ACTION = 6;
+    int DISMISS_NO_LONGER_BUBBLE = 7;
+    int DISMISS_USER_CHANGED = 8;
+    int DISMISS_GROUP_CANCELLED = 9;
+    int DISMISS_INVALID_INTENT = 10;
+    int DISMISS_OVERFLOW_MAX_REACHED = 11;
+    int DISMISS_SHORTCUT_REMOVED = 12;
+    int DISMISS_PACKAGE_REMOVED = 13;
+    int DISMISS_NO_BUBBLE_UP = 14;
+
+    /**
+     * @return {@code true} if there is a bubble associated with the provided key and if its
+     * notification is hidden from the shade or there is a group summary associated with the
+     * provided key that is hidden from the shade because it has been dismissed but still has child
+     * bubbles active.
+     */
+    boolean isBubbleNotificationSuppressedFromShade(String key, String groupKey);
+
+    /**
+     * @return {@code true} if the current notification entry same as selected bubble
+     * notification entry and the stack is currently expanded.
+     */
+    boolean isBubbleExpanded(String key);
+
+    /** @return {@code true} if stack of bubbles is expanded or not. */
+    boolean isStackExpanded();
+
+    /** @return {@code true} if the summary for the provided group key is suppressed. */
+    boolean isSummarySuppressed(String groupKey);
+
+    /**
+     * Removes a group key indicating that summary for this group should no longer be suppressed.
+     */
+    void removeSuppressedSummary(String groupKey);
+
+    /** Tell the stack of bubbles to collapse. */
+    void collapseStack();
+
+    /** Tell the controller need update its UI to fit theme. */
+    void updateForThemeChanges();
+
+    /**
+     * Request the stack expand if needed, then select the specified Bubble as current.
+     * If no bubble exists for this entry, one is created.
+     *
+     * @param entry the notification for the bubble to be selected
+     */
+    void expandStackAndSelectBubble(BubbleEntry entry);
+
+    /**
+     * We intercept notification entries (including group summaries) dismissed by the user when
+     * there is an active bubble associated with it. We do this so that developers can still
+     * cancel it (and hence the bubbles associated with it). However, these intercepted
+     * notifications should then be hidden from the shade since the user has cancelled them, so we
+     * {@link Bubble#setSuppressNotification}.  For the case of suppressed summaries, we also add
+     * {@link BubbleData#addSummaryToSuppress}.
+     *
+     * @param entry the notification of the BubbleEntry should be removed.
+     * @param children the list of child notification of the BubbleEntry from 1st param entry,
+     *                 this will be null if entry does have no children.
+     * @param removeCallback the remove callback for SystemUI side to remove notification, the int
+     *                       number should be list position of children list and use -1 for
+     *                       removing the parent notification.
+     *
+     * @return true if we want to intercept the dismissal of the entry, else false.
+     */
+    boolean handleDismissalInterception(BubbleEntry entry, @Nullable List<BubbleEntry> children,
+            IntConsumer removeCallback);
+
+    /**
+     * Retrieves the notif entry key of the summary associated with the provided group key.
+     *
+     * @param groupKey the group to look up
+     * @return the key for the notification that is the summary of this group.
+     */
+    String getSummaryKey(String groupKey);
+
+    /** Set the proxy to commnuicate with SysUi side components. */
+    void setSysuiProxy(SysuiProxy proxy);
+
+    /** Set the scrim view for bubbles. */
+    void setBubbleScrim(View view);
+
+    /** Set a listener to be notified of bubble expand events. */
+    void setExpandListener(BubbleExpandListener listener);
+
+    /**
+     * Called when new notification entry added.
+     *
+     * @param entry the {@link BubbleEntry} by the notification.
+     */
+    void onEntryAdded(BubbleEntry entry);
+
+    /**
+     * Called when new notification entry updated.
+     *
+     * @param entry the {@link BubbleEntry} by the notification.
+     * @param shouldBubbleUp {@code true} if this notification should bubble up.
+     */
+    void onEntryUpdated(BubbleEntry entry, boolean shouldBubbleUp);
+
+    /**
+     * Called when new notification entry removed.
+     *
+     * @param entry the {@link BubbleEntry} by the notification.
+     */
+    void onEntryRemoved(BubbleEntry entry);
+
+    /**
+     * Called when NotificationListener has received adjusted notification rank and reapplied
+     * filtering and sorting. This is used to dismiss or create bubbles based on changes in
+     * permissions on the notification channel or the global setting.
+     *
+     * @param rankingMap the updated ranking map from NotificationListenerService
+     */
+    void onRankingUpdated(RankingMap rankingMap);
+
+    /**
+     * Called when the status bar has become visible or invisible (either permanently or
+     * temporarily).
+     */
+    void onStatusBarVisibilityChanged(boolean visible);
+
+    /** Called when system zen mode state changed. */
+    void onZenStateChanged();
+
+    /**
+     * Called when statusBar state changed.
+     *
+     * @param isShade {@code true} is state is SHADE.
+     */
+    void onStatusBarStateChanged(boolean isShade);
+
+    /**
+     * Called when the current user changed.
+     *
+     * @param newUserId the new user's id.
+     */
+    void onUserChanged(int newUserId);
+
+    /**
+     * Called when config changed.
+     *
+     * @param newConfig the new config.
+     */
+    void onConfigChanged(Configuration newConfig);
+
+    /** Description of current bubble state. */
+    void dump(FileDescriptor fd, PrintWriter pw, String[] args);
+
+    /** Listener to find out about stack expansion / collapse events. */
+    interface BubbleExpandListener {
+        /**
+         * Called when the expansion state of the bubble stack changes.
+         *
+         * @param isExpanding whether it's expanding or collapsing
+         * @param key the notification key associated with bubble being expanded
+         */
+        void onBubbleExpandChanged(boolean isExpanding, String key);
+    }
+
+    /** Listener to be notified when a bubbles' notification suppression state changes.*/
+    interface NotificationSuppressionChangedListener {
+        /** Called when the notification suppression state of a bubble changes. */
+        void onBubbleNotificationSuppressionChange(Bubble bubble);
+    }
+
+    /** Listener to be notified when a pending intent has been canceled for a bubble. */
+    interface PendingIntentCanceledListener {
+        /** Called when the pending intent for a bubble has been canceled. */
+        void onPendingIntentCanceled(Bubble bubble);
+    }
+
+    /** Callback to tell SysUi components execute some methods. */
+    interface SysuiProxy {
+        @Nullable
+        BubbleEntry getPendingOrActiveEntry(String key);
+
+        List<BubbleEntry> getShouldRestoredEntries(ArraySet<String> savedBubbleKeys);
+
+        boolean isNotificationShadeExpand();
+
+        boolean shouldBubbleUp(String key);
+
+        void setNotificationInterruption(String key);
+
+        void requestNotificationShadeTopUi(boolean requestTopUi, String componentTag);
+
+        void notifyRemoveNotification(String key, int reason);
+
+        void notifyInvalidateNotifications(String reason);
+
+        void notifyMaybeCancelSummary(String key);
+
+        void removeNotificationEntry(String key);
+
+        void updateNotificationBubbleButton(String key);
+
+        void updateNotificationSuppression(String key);
+
+        void onStackExpandChanged(boolean shouldExpand);
+
+        void onUnbubbleConversation(String key);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/DismissView.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/DismissView.kt
new file mode 100644
index 0000000..04b5ad6
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/DismissView.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.content.Context
+import android.graphics.drawable.TransitionDrawable
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.SpringForce.DAMPING_RATIO_LOW_BOUNCY
+import androidx.dynamicanimation.animation.SpringForce.STIFFNESS_LOW
+import com.android.wm.shell.R
+import com.android.wm.shell.animation.PhysicsAnimator
+import com.android.wm.shell.common.DismissCircleView
+
+/*
+ * View that handles interactions between DismissCircleView and BubbleStackView.
+ */
+class DismissView(context: Context) : FrameLayout(context) {
+
+    var circle = DismissCircleView(context).apply {
+        val targetSize: Int = context.resources.getDimensionPixelSize(R.dimen.dismiss_circle_size)
+        val newParams = LayoutParams(targetSize, targetSize)
+        newParams.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+        setLayoutParams(newParams)
+        setTranslationY(
+            resources.getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height).toFloat())
+    }
+
+    var isShowing = false
+    private val animator = PhysicsAnimator.getInstance(circle)
+    private val spring = PhysicsAnimator.SpringConfig(STIFFNESS_LOW, DAMPING_RATIO_LOW_BOUNCY)
+    private val DISMISS_SCRIM_FADE_MS = 200
+    init {
+        setLayoutParams(LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            resources.getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height),
+            Gravity.BOTTOM))
+        setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.floating_dismiss_bottom_margin))
+        setClipToPadding(false)
+        setClipChildren(false)
+        setVisibility(View.INVISIBLE)
+        setBackgroundResource(
+            R.drawable.floating_dismiss_gradient_transition)
+        addView(circle)
+    }
+
+    /**
+     * Animates this view in.
+     */
+    fun show() {
+        if (isShowing) return
+        isShowing = true
+        bringToFront()
+        setZ(Short.MAX_VALUE - 1f)
+        setVisibility(View.VISIBLE)
+        (getBackground() as TransitionDrawable).startTransition(DISMISS_SCRIM_FADE_MS)
+        animator.cancel()
+        animator
+            .spring(DynamicAnimation.TRANSLATION_Y, 0f, spring)
+            .start()
+    }
+
+    /**
+     * Animates this view out, as well as the circle that encircles the bubbles, if they
+     * were dragged into the target and encircled.
+     */
+    fun hide() {
+        if (!isShowing) return
+        isShowing = false
+        (getBackground() as TransitionDrawable).reverseTransition(DISMISS_SCRIM_FADE_MS)
+        animator
+            .spring(DynamicAnimation.TRANSLATION_Y, height.toFloat(),
+                spring)
+            .withEndActions({ setVisibility(View.INVISIBLE) })
+            .start()
+    }
+
+    fun updateResources() {
+        val targetSize: Int = context.resources.getDimensionPixelSize(R.dimen.dismiss_circle_size)
+        circle.layoutParams.width = targetSize
+        circle.layoutParams.height = targetSize
+        circle.requestLayout()
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ManageEducationView.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ManageEducationView.kt
new file mode 100644
index 0000000..4cc6702
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ManageEducationView.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Rect
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.android.internal.util.ContrastColorUtil
+import com.android.wm.shell.R
+import com.android.wm.shell.animation.Interpolators
+
+/**
+ * User education view to highlight the manage button that allows a user to configure the settings
+ * for the bubble. Shown only the first time a user expands a bubble.
+ */
+class ManageEducationView constructor(context: Context) : LinearLayout(context) {
+
+    private val TAG = if (BubbleDebugConfig.TAG_WITH_CLASS_NAME) "BubbleManageEducationView"
+        else BubbleDebugConfig.TAG_BUBBLES
+
+    private val ANIMATE_DURATION: Long = 200
+    private val ANIMATE_DURATION_SHORT: Long = 40
+
+    private val manageView by lazy { findViewById<View>(R.id.manage_education_view) }
+    private val manageButton by lazy { findViewById<Button>(R.id.manage) }
+    private val gotItButton by lazy { findViewById<Button>(R.id.got_it) }
+    private val titleTextView by lazy { findViewById<TextView>(R.id.user_education_title) }
+    private val descTextView by lazy { findViewById<TextView>(R.id.user_education_description) }
+
+    private var isHiding = false
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.bubbles_manage_button_education, this)
+        visibility = View.GONE
+        elevation = resources.getDimensionPixelSize(R.dimen.bubble_elevation).toFloat()
+
+        // BubbleStackView forces LTR by default
+        // since most of Bubble UI direction depends on positioning by the user.
+        // This view actually lays out differently in RTL, so we set layout LOCALE here.
+        layoutDirection = View.LAYOUT_DIRECTION_LOCALE
+    }
+
+    override fun setLayoutDirection(layoutDirection: Int) {
+        super.setLayoutDirection(layoutDirection)
+        setDrawableDirection()
+    }
+
+    override fun onFinishInflate() {
+        super.onFinishInflate()
+        layoutDirection = resources.configuration.layoutDirection
+        setTextColor()
+    }
+
+    private fun setTextColor() {
+        val typedArray = mContext.obtainStyledAttributes(intArrayOf(android.R.attr.colorAccent,
+            android.R.attr.textColorPrimaryInverse))
+        val bgColor = typedArray.getColor(0 /* index */, Color.BLACK)
+        var textColor = typedArray.getColor(1 /* index */, Color.WHITE)
+        typedArray.recycle()
+        textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, true)
+        titleTextView.setTextColor(textColor)
+        descTextView.setTextColor(textColor)
+    }
+
+    private fun setDrawableDirection() {
+        manageView.setBackgroundResource(
+            if (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL)
+                R.drawable.bubble_stack_user_education_bg_rtl
+            else R.drawable.bubble_stack_user_education_bg)
+    }
+
+    /**
+     * If necessary, toggles the user education view for the manage button. This is shown when the
+     * bubble stack is expanded for the first time.
+     *
+     * @param show whether the user education view should show or not.
+     */
+    fun show(expandedView: BubbleExpandedView, rect: Rect) {
+        if (visibility == VISIBLE) return
+
+        alpha = 0f
+        visibility = View.VISIBLE
+        post {
+            expandedView.getManageButtonBoundsOnScreen(rect)
+
+            manageButton
+                .setOnClickListener {
+                    expandedView.findViewById<View>(R.id.settings_button).performClick()
+                    hide(true /* isStackExpanding */)
+                }
+            gotItButton.setOnClickListener { hide(true /* isStackExpanding */) }
+            setOnClickListener { hide(true /* isStackExpanding */) }
+
+            with(manageView) {
+                translationX = 0f
+                val inset = resources.getDimensionPixelSize(
+                    R.dimen.bubbles_manage_education_top_inset)
+                translationY = (rect.top - manageView.height + inset).toFloat()
+            }
+            bringToFront()
+            animate()
+                .setDuration(ANIMATE_DURATION)
+                .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
+                .alpha(1f)
+        }
+        setShouldShow(false)
+    }
+
+    fun hide(isStackExpanding: Boolean) {
+        if (visibility != VISIBLE || isHiding) return
+
+        animate()
+            .withStartAction { isHiding = true }
+            .alpha(0f)
+            .setDuration(if (isStackExpanding) ANIMATE_DURATION_SHORT else ANIMATE_DURATION)
+            .withEndAction {
+                isHiding = false
+                visibility = GONE
+            }
+    }
+
+    private fun setShouldShow(shouldShow: Boolean) {
+        context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
+                .edit().putBoolean(PREF_MANAGED_EDUCATION, !shouldShow).apply()
+    }
+}
+
+const val PREF_MANAGED_EDUCATION: String = "HasSeenBubblesManageOnboarding"
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ObjectWrapper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ObjectWrapper.java
new file mode 100644
index 0000000..528907f
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/ObjectWrapper.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.os.Binder;
+import android.os.IBinder;
+
+// Copied from Launcher3
+/**
+ * Utility class to pass non-parcealable objects within same process using parcealable payload.
+ *
+ * It wraps the object in a binder as binders are singleton within a process
+ */
+public class ObjectWrapper<T> extends Binder {
+
+    private T mObject;
+
+    public ObjectWrapper(T object) {
+        mObject = object;
+    }
+
+    public T get() {
+        return mObject;
+    }
+
+    public void clear() {
+        mObject = null;
+    }
+
+    public static IBinder wrap(Object obj) {
+        return new ObjectWrapper<>(obj);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt
new file mode 100644
index 0000000..b347329
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.graphics.PointF
+import android.os.Handler
+import android.view.MotionEvent
+import android.view.VelocityTracker
+import android.view.View
+import android.view.ViewConfiguration
+import kotlin.math.hypot
+
+/**
+ * Listener which receives [onDown], [onMove], and [onUp] events, with relevant information about
+ * the coordinates of the touch and the view relative to the initial ACTION_DOWN event and the
+ * view's initial position.
+ */
+abstract class RelativeTouchListener : View.OnTouchListener {
+
+    /**
+     * Called when an ACTION_DOWN event is received for the given view.
+     *
+     * @return False if the object is not interested in MotionEvents at this time, or true if we
+     * should consume this event and subsequent events, and begin calling [onMove].
+     */
+    abstract fun onDown(v: View, ev: MotionEvent): Boolean
+
+    /**
+     * Called when an ACTION_MOVE event is received for the given view. This signals that the view
+     * is being dragged.
+     *
+     * @param viewInitialX The view's translationX value when this touch gesture started.
+     * @param viewInitialY The view's translationY value when this touch gesture started.
+     * @param dx Horizontal distance covered since the initial ACTION_DOWN event, in pixels.
+     * @param dy Vertical distance covered since the initial ACTION_DOWN event, in pixels.
+     */
+    abstract fun onMove(
+        v: View,
+        ev: MotionEvent,
+        viewInitialX: Float,
+        viewInitialY: Float,
+        dx: Float,
+        dy: Float
+    )
+
+    /**
+     * Called when an ACTION_UP event is received for the given view. This signals that a drag or
+     * fling gesture has completed.
+     *
+     * @param viewInitialX The view's translationX value when this touch gesture started.
+     * @param viewInitialY The view's translationY value when this touch gesture started.
+     * @param dx Horizontal distance covered, in pixels.
+     * @param dy Vertical distance covered, in pixels.
+     * @param velX The final horizontal velocity of the gesture, in pixels/second.
+     * @param velY The final vertical velocity of the gesture, in pixels/second.
+     */
+    abstract fun onUp(
+        v: View,
+        ev: MotionEvent,
+        viewInitialX: Float,
+        viewInitialY: Float,
+        dx: Float,
+        dy: Float,
+        velX: Float,
+        velY: Float
+    )
+
+    /** The raw coordinates of the last ACTION_DOWN event. */
+    private val touchDown = PointF()
+
+    /** The coordinates of the view, at the time of the last ACTION_DOWN event. */
+    private val viewPositionOnTouchDown = PointF()
+
+    private val velocityTracker = VelocityTracker.obtain()
+
+    private var touchSlop: Int = -1
+    private var movedEnough = false
+
+    private val handler = Handler()
+    private var performedLongClick = false
+
+    @Suppress("UNCHECKED_CAST")
+    override fun onTouch(v: View, ev: MotionEvent): Boolean {
+        addMovement(ev)
+
+        val dx = ev.rawX - touchDown.x
+        val dy = ev.rawY - touchDown.y
+
+        when (ev.action) {
+            MotionEvent.ACTION_DOWN -> {
+                if (!onDown(v, ev)) {
+                    return false
+                }
+
+                // Grab the touch slop, it might have changed if the config changed since the
+                // last gesture.
+                touchSlop = ViewConfiguration.get(v.context).scaledTouchSlop
+
+                touchDown.set(ev.rawX, ev.rawY)
+                viewPositionOnTouchDown.set(v.translationX, v.translationY)
+
+                performedLongClick = false
+                handler.postDelayed({
+                    if (v.isLongClickable) {
+                        performedLongClick = v.performLongClick()
+                    }
+                }, ViewConfiguration.getLongPressTimeout().toLong())
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                if (!movedEnough && hypot(dx, dy) > touchSlop && !performedLongClick) {
+                    movedEnough = true
+                    handler.removeCallbacksAndMessages(null)
+                }
+
+                if (movedEnough) {
+                    onMove(v, ev, viewPositionOnTouchDown.x, viewPositionOnTouchDown.y, dx, dy)
+                }
+            }
+
+            MotionEvent.ACTION_UP -> {
+                if (movedEnough) {
+                    velocityTracker.computeCurrentVelocity(1000 /* units */)
+                    onUp(v, ev, viewPositionOnTouchDown.x, viewPositionOnTouchDown.y, dx, dy,
+                            velocityTracker.xVelocity, velocityTracker.yVelocity)
+                } else if (!performedLongClick) {
+                    v.performClick()
+                } else {
+                    handler.removeCallbacksAndMessages(null)
+                }
+
+                velocityTracker.clear()
+                movedEnough = false
+            }
+        }
+
+        return true
+    }
+
+    /**
+     * Adds a movement to the velocity tracker using raw screen coordinates.
+     */
+    private fun addMovement(event: MotionEvent) {
+        val deltaX = event.rawX - event.x
+        val deltaY = event.rawY - event.y
+        event.offsetLocation(deltaX, deltaY)
+        velocityTracker.addMovement(event)
+        event.offsetLocation(-deltaX, -deltaY)
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/StackEducationView.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/StackEducationView.kt
new file mode 100644
index 0000000..04c4dfb
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/StackEducationView.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.PointF
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.android.internal.util.ContrastColorUtil
+import com.android.wm.shell.R
+import com.android.wm.shell.animation.Interpolators
+
+/**
+ * User education view to highlight the collapsed stack of bubbles.
+ * Shown only the first time a user taps the stack.
+ */
+class StackEducationView constructor(context: Context) : LinearLayout(context) {
+
+    private val TAG = if (BubbleDebugConfig.TAG_WITH_CLASS_NAME) "BubbleStackEducationView"
+        else BubbleDebugConfig.TAG_BUBBLES
+
+    private val ANIMATE_DURATION: Long = 200
+    private val ANIMATE_DURATION_SHORT: Long = 40
+
+    private val view by lazy { findViewById<View>(R.id.stack_education_layout) }
+    private val titleTextView by lazy { findViewById<TextView>(R.id.stack_education_title) }
+    private val descTextView by lazy { findViewById<TextView>(R.id.stack_education_description) }
+
+    private var isHiding = false
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.bubble_stack_user_education, this)
+
+        visibility = View.GONE
+        elevation = resources.getDimensionPixelSize(R.dimen.bubble_elevation).toFloat()
+
+        // BubbleStackView forces LTR by default
+        // since most of Bubble UI direction depends on positioning by the user.
+        // This view actually lays out differently in RTL, so we set layout LOCALE here.
+        layoutDirection = View.LAYOUT_DIRECTION_LOCALE
+    }
+
+    override fun setLayoutDirection(layoutDirection: Int) {
+        super.setLayoutDirection(layoutDirection)
+        setDrawableDirection()
+    }
+
+    override fun onFinishInflate() {
+        super.onFinishInflate()
+        layoutDirection = resources.configuration.layoutDirection
+        setTextColor()
+    }
+
+    private fun setTextColor() {
+        val ta = mContext.obtainStyledAttributes(intArrayOf(android.R.attr.colorAccent,
+            android.R.attr.textColorPrimaryInverse))
+        val bgColor = ta.getColor(0 /* index */, Color.BLACK)
+        var textColor = ta.getColor(1 /* index */, Color.WHITE)
+        ta.recycle()
+        textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, true)
+        titleTextView.setTextColor(textColor)
+        descTextView.setTextColor(textColor)
+    }
+
+    private fun setDrawableDirection() {
+        view.setBackgroundResource(
+            if (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR)
+                R.drawable.bubble_stack_user_education_bg
+            else R.drawable.bubble_stack_user_education_bg_rtl)
+    }
+
+    /**
+     * If necessary, shows the user education view for the bubble stack. This appears the first
+     * time a user taps on a bubble.
+     *
+     * @return true if user education was shown, false otherwise.
+     */
+    fun show(stackPosition: PointF): Boolean {
+        if (visibility == VISIBLE) return false
+
+        setAlpha(0f)
+        setVisibility(View.VISIBLE)
+        post {
+            with(view) {
+                val bubbleSize = context.resources.getDimensionPixelSize(
+                    R.dimen.individual_bubble_size)
+                translationY = stackPosition.y + bubbleSize / 2 - getHeight() / 2
+            }
+            animate()
+                .setDuration(ANIMATE_DURATION)
+                .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
+                .alpha(1f)
+        }
+        setShouldShow(false)
+        return true
+    }
+
+    /**
+     * If necessary, hides the stack education view.
+     *
+     * @param fromExpansion if true this indicates the hide is happening due to the bubble being
+     *                      expanded, false if due to a touch outside of the bubble stack.
+     */
+    fun hide(fromExpansion: Boolean) {
+        if (visibility != VISIBLE || isHiding) return
+
+        animate()
+            .alpha(0f)
+            .setDuration(if (fromExpansion) ANIMATE_DURATION_SHORT else ANIMATE_DURATION)
+            .withEndAction { visibility = GONE }
+    }
+
+    private fun setShouldShow(shouldShow: Boolean) {
+        context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
+                .edit().putBoolean(PREF_STACK_EDUCATION, !shouldShow).apply()
+    }
+}
+
+const val PREF_STACK_EDUCATION: String = "HasSeenBubblesOnboarding"
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java
new file mode 100644
index 0000000..2612b81
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/AnimatableScaleMatrix.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.animation;
+
+import android.graphics.Matrix;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+
+/**
+ * Matrix whose scale properties can be animated using physics animations, via the {@link #SCALE_X}
+ * and {@link #SCALE_Y} FloatProperties.
+ *
+ * This is useful when you need to perform a scale animation with a pivot point, since pivot points
+ * are not supported by standard View scale operations but are supported by matrices.
+ *
+ * NOTE: DynamicAnimation assumes that all custom properties are denominated in pixels, and thus
+ * considers 1 to be the smallest user-visible change for custom properties. This means that if you
+ * animate {@link #SCALE_X} and {@link #SCALE_Y} to 3f, for example, the animation would have only
+ * three frames.
+ *
+ * To work around this, whenever animating to a desired scale value, animate to the value returned
+ * by {@link #getAnimatableValueForScaleFactor} instead. The SCALE_X and SCALE_Y properties will
+ * convert that (larger) value into the appropriate scale factor when scaling the matrix.
+ */
+public class AnimatableScaleMatrix extends Matrix {
+
+    /**
+     * The X value of the scale.
+     *
+     * NOTE: This must be set or animated to the value returned by
+     * {@link #getAnimatableValueForScaleFactor}, not the desired scale factor itself.
+     */
+    public static final FloatPropertyCompat<AnimatableScaleMatrix> SCALE_X =
+            new FloatPropertyCompat<AnimatableScaleMatrix>("matrixScaleX") {
+        @Override
+        public float getValue(AnimatableScaleMatrix object) {
+            return getAnimatableValueForScaleFactor(object.mScaleX);
+        }
+
+        @Override
+        public void setValue(AnimatableScaleMatrix object, float value) {
+            object.setScaleX(value * DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+        }
+    };
+
+    /**
+     * The Y value of the scale.
+     *
+     * NOTE: This must be set or animated to the value returned by
+     * {@link #getAnimatableValueForScaleFactor}, not the desired scale factor itself.
+     */
+    public static final FloatPropertyCompat<AnimatableScaleMatrix> SCALE_Y =
+            new FloatPropertyCompat<AnimatableScaleMatrix>("matrixScaleY") {
+                @Override
+                public float getValue(AnimatableScaleMatrix object) {
+                    return getAnimatableValueForScaleFactor(object.mScaleY);
+                }
+
+                @Override
+                public void setValue(AnimatableScaleMatrix object, float value) {
+                    object.setScaleY(value * DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+                }
+            };
+
+    private float mScaleX = 1f;
+    private float mScaleY = 1f;
+
+    private float mPivotX = 0f;
+    private float mPivotY = 0f;
+
+    /**
+     * Return the value to animate SCALE_X or SCALE_Y to in order to achieve the desired scale
+     * factor.
+     */
+    public static float getAnimatableValueForScaleFactor(float scale) {
+        return scale * (1f / DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+    }
+
+    @Override
+    public void setScale(float sx, float sy, float px, float py) {
+        mScaleX = sx;
+        mScaleY = sy;
+        mPivotX = px;
+        mPivotY = py;
+        super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+    }
+
+    public void setScaleX(float scaleX) {
+        mScaleX = scaleX;
+        super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+    }
+
+    public void setScaleY(float scaleY) {
+        mScaleY = scaleY;
+        super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+    }
+
+    public void setPivotX(float pivotX) {
+        mPivotX = pivotX;
+        super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+    }
+
+    public void setPivotY(float pivotY) {
+        mPivotY = pivotY;
+        super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+    }
+
+    public float getScaleX() {
+        return mScaleX;
+    }
+
+    public float getScaleY() {
+        return mScaleY;
+    }
+
+    public float getPivotX() {
+        return mPivotX;
+    }
+
+    public float getPivotY() {
+        return mPivotY;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        // Use object equality to allow this matrix to be used as a map key (which is required for
+        // PhysicsAnimator's animator caching).
+        return obj == this;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
new file mode 100644
index 0000000..61fbf81
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
@@ -0,0 +1,635 @@
+/*
+ * Copyright (C) 2019 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.wm.shell.bubbles.animation;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.bubbles.BubblePositioner;
+import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
+
+import com.google.android.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.Set;
+
+/**
+ * Animation controller for bubbles when they're in their expanded state, or animating to/from the
+ * expanded state. This controls the expansion animation as well as bubbles 'dragging out' to be
+ * dismissed.
+ */
+public class ExpandedAnimationController
+        extends PhysicsAnimationLayout.PhysicsAnimationController {
+
+    /**
+     * How much to translate the bubbles when they're animating in/out. This value is multiplied by
+     * the bubble size.
+     */
+    private static final int ANIMATE_TRANSLATION_FACTOR = 4;
+
+    /** Duration of the expand/collapse target path animation. */
+    public static final int EXPAND_COLLAPSE_TARGET_ANIM_DURATION = 175;
+
+    /** Damping ratio for expand/collapse spring. */
+    private static final float DAMPING_RATIO_MEDIUM_LOW_BOUNCY = 0.65f;
+
+    /** Stiffness for the expand/collapse path-following animation. */
+    private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 1000;
+
+    /** What percentage of the screen to use when centering the bubbles in landscape. */
+    private static final float CENTER_BUBBLES_LANDSCAPE_PERCENT = 0.66f;
+
+    /**
+     * Velocity required to dismiss an individual bubble without dragging it into the dismiss
+     * target.
+     */
+    private static final float FLING_TO_DISMISS_MIN_VELOCITY = 6000f;
+
+    private final PhysicsAnimator.SpringConfig mAnimateOutSpringConfig =
+            new PhysicsAnimator.SpringConfig(
+                    EXPAND_COLLAPSE_ANIM_STIFFNESS, SpringForce.DAMPING_RATIO_NO_BOUNCY);
+
+    /** Horizontal offset between bubbles, which we need to know to re-stack them. */
+    private float mStackOffsetPx;
+    /** Space between status bar and bubbles in the expanded state. */
+    private float mBubblePaddingTop;
+    /** Size of each bubble. */
+    private float mBubbleSizePx;
+    /** Max number of bubbles shown in row above expanded view. */
+    private int mBubblesMaxRendered;
+
+    private boolean mAnimatingExpand = false;
+
+    /**
+     * Whether we are animating other Bubbles UI elements out in preparation for a call to
+     * {@link #collapseBackToStack}. If true, we won't animate bubbles in response to adds or
+     * reorders.
+     */
+    private boolean mPreparingToCollapse = false;
+
+    private boolean mAnimatingCollapse = false;
+    @Nullable
+    private Runnable mAfterExpand;
+    private Runnable mAfterCollapse;
+    private PointF mCollapsePoint;
+
+    /**
+     * Whether the dragged out bubble is springing towards the touch point, rather than using the
+     * default behavior of moving directly to the touch point.
+     *
+     * This happens when the user's finger exits the dismiss area while the bubble is magnetized to
+     * the center. Since the touch point differs from the bubble location, we need to animate the
+     * bubble back to the touch point to avoid a jarring instant location change from the center of
+     * the target to the touch point just outside the target bounds.
+     */
+    private boolean mSpringingBubbleToTouch = false;
+
+    /**
+     * Whether to spring the bubble to the next touch event coordinates. This is used to animate the
+     * bubble out of the magnetic dismiss target to the touch location.
+     *
+     * Once it 'catches up' and the animation ends, we'll revert to moving it directly.
+     */
+    private boolean mSpringToTouchOnNextMotionEvent = false;
+
+    /** The bubble currently being dragged out of the row (to potentially be dismissed). */
+    private MagnetizedObject<View> mMagnetizedBubbleDraggingOut;
+
+    private int mExpandedViewPadding;
+
+    /**
+     * Callback to run whenever any bubble is animated out. The BubbleStackView will check if the
+     * end of this animation means we have no bubbles left, and notify the BubbleController.
+     */
+    private Runnable mOnBubbleAnimatedOutAction;
+
+    private BubblePositioner mPositioner;
+
+    public ExpandedAnimationController(BubblePositioner positioner, int expandedViewPadding,
+            Runnable onBubbleAnimatedOutAction) {
+        mPositioner = positioner;
+        updateResources();
+        mExpandedViewPadding = expandedViewPadding;
+        mOnBubbleAnimatedOutAction = onBubbleAnimatedOutAction;
+    }
+
+    /**
+     * Whether the individual bubble has been dragged out of the row of bubbles far enough to cause
+     * the rest of the bubbles to animate to fill the gap.
+     */
+    private boolean mBubbleDraggedOutEnough = false;
+
+    /** End action to run when the lead bubble's expansion animation completes. */
+    @Nullable
+    private Runnable mLeadBubbleEndAction;
+
+    /**
+     * Animates expanding the bubbles into a row along the top of the screen, optionally running an
+     * end action when the entire animation completes, and an end action when the lead bubble's
+     * animation ends.
+     */
+    public void expandFromStack(
+            @Nullable Runnable after, @Nullable Runnable leadBubbleEndAction) {
+        mPreparingToCollapse = false;
+        mAnimatingCollapse = false;
+        mAnimatingExpand = true;
+        mAfterExpand = after;
+        mLeadBubbleEndAction = leadBubbleEndAction;
+
+        startOrUpdatePathAnimation(true /* expanding */);
+    }
+
+    /**
+     * Animates expanding the bubbles into a row along the top of the screen.
+     */
+    public void expandFromStack(@Nullable Runnable after) {
+        expandFromStack(after, null /* leadBubbleEndAction */);
+    }
+
+    /**
+     * Sets that we're animating the stack collapsed, but haven't yet called
+     * {@link #collapseBackToStack}. This will temporarily suspend animations for bubbles that are
+     * added or re-ordered, since the upcoming collapse animation will handle positioning those
+     * bubbles in the collapsed stack.
+     */
+    public void notifyPreparingToCollapse() {
+        mPreparingToCollapse = true;
+    }
+
+    /** Animate collapsing the bubbles back to their stacked position. */
+    public void collapseBackToStack(PointF collapsePoint, Runnable after) {
+        mAnimatingExpand = false;
+        mPreparingToCollapse = false;
+        mAnimatingCollapse = true;
+        mAfterCollapse = after;
+        mCollapsePoint = collapsePoint;
+
+        startOrUpdatePathAnimation(false /* expanding */);
+    }
+
+    /**
+     * Update effective screen width based on current orientation.
+     */
+    public void updateResources() {
+        if (mLayout == null) {
+            return;
+        }
+        Resources res = mLayout.getContext().getResources();
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        mStackOffsetPx = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        mBubbleSizePx = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mBubblesMaxRendered = res.getInteger(R.integer.bubbles_max_rendered);
+    }
+
+    /**
+     * Animates the bubbles along a curved path, either to expand them along the top or collapse
+     * them back into a stack.
+     */
+    private void startOrUpdatePathAnimation(boolean expanding) {
+        Runnable after;
+
+        if (expanding) {
+            after = () -> {
+                mAnimatingExpand = false;
+
+                if (mAfterExpand != null) {
+                    mAfterExpand.run();
+                }
+
+                mAfterExpand = null;
+
+                // Update bubble positions in case any bubbles were added or removed during the
+                // expansion animation.
+                updateBubblePositions();
+            };
+        } else {
+            after = () -> {
+                mAnimatingCollapse = false;
+
+                if (mAfterCollapse != null) {
+                    mAfterCollapse.run();
+                }
+
+                mAfterCollapse = null;
+            };
+        }
+
+        // Animate each bubble individually, since each path will end in a different spot.
+        animationsForChildrenFromIndex(0, (index, animation) -> {
+            final View bubble = mLayout.getChildAt(index);
+
+            // Start a path at the bubble's current position.
+            final Path path = new Path();
+            path.moveTo(bubble.getTranslationX(), bubble.getTranslationY());
+
+            final float expandedY = getExpandedY();
+            if (expanding) {
+                // If we're expanding, first draw a line from the bubble's current position to the
+                // top of the screen.
+                path.lineTo(bubble.getTranslationX(), expandedY);
+                // Then, draw a line across the screen to the bubble's resting position.
+                if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+                    Rect availableRect = mPositioner.getAvailableRect();
+                    boolean onLeft = mCollapsePoint != null
+                            && mCollapsePoint.x < (availableRect.width() / 2f);
+                    float translationX = onLeft
+                            ? availableRect.left + mExpandedViewPadding
+                            : availableRect.right - mBubbleSizePx - mExpandedViewPadding;
+                    path.lineTo(translationX, getBubbleXOrYForOrientation(index));
+                } else {
+                    path.lineTo(getBubbleXOrYForOrientation(index), expandedY);
+                }
+            } else {
+                final float stackedX = mCollapsePoint.x;
+
+                // If we're collapsing, draw a line from the bubble's current position to the side
+                // of the screen where the bubble will be stacked.
+                path.lineTo(stackedX, expandedY);
+
+                // Then, draw a line down to the stack position.
+                path.lineTo(stackedX, mCollapsePoint.y + index * mStackOffsetPx);
+            }
+
+            // The lead bubble should be the bubble with the longest distance to travel when we're
+            // expanding, and the bubble with the shortest distance to travel when we're collapsing.
+            // During expansion from the left side, the last bubble has to travel to the far right
+            // side, so we have it lead and 'pull' the rest of the bubbles into place. From the
+            // right side, the first bubble is traveling to the top left, so it leads. During
+            // collapse to the left, the first bubble has the shortest travel time back to the stack
+            // position, so it leads (and vice versa).
+            final boolean firstBubbleLeads =
+                    (expanding && !mLayout.isFirstChildXLeftOfCenter(bubble.getTranslationX()))
+                            || (!expanding && mLayout.isFirstChildXLeftOfCenter(mCollapsePoint.x));
+            final int startDelay = firstBubbleLeads
+                    ? (index * 10)
+                    : ((mLayout.getChildCount() - index) * 10);
+
+            final boolean isLeadBubble =
+                    (firstBubbleLeads && index == 0)
+                            || (!firstBubbleLeads && index == mLayout.getChildCount() - 1);
+
+            animation
+                    .followAnimatedTargetAlongPath(
+                            path,
+                            EXPAND_COLLAPSE_TARGET_ANIM_DURATION /* targetAnimDuration */,
+                            Interpolators.LINEAR /* targetAnimInterpolator */,
+                            isLeadBubble ? mLeadBubbleEndAction : null /* endAction */,
+                            () -> mLeadBubbleEndAction = null /* endAction */)
+                    .withStartDelay(startDelay)
+                    .withStiffness(EXPAND_COLLAPSE_ANIM_STIFFNESS);
+        }).startAll(after);
+    }
+
+    /** Notifies the controller that the dragged-out bubble was unstuck from the magnetic target. */
+    public void onUnstuckFromTarget() {
+        mSpringToTouchOnNextMotionEvent = true;
+    }
+
+    /**
+     * Prepares the given bubble view to be dragged out, using the provided magnetic target and
+     * listener.
+     */
+    public void prepareForBubbleDrag(
+            View bubble,
+            MagnetizedObject.MagneticTarget target,
+            MagnetizedObject.MagnetListener listener) {
+        mLayout.cancelAnimationsOnView(bubble);
+
+        bubble.setTranslationZ(Short.MAX_VALUE);
+        mMagnetizedBubbleDraggingOut = new MagnetizedObject<View>(
+                mLayout.getContext(), bubble,
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y) {
+            @Override
+            public float getWidth(@NonNull View underlyingObject) {
+                return mBubbleSizePx;
+            }
+
+            @Override
+            public float getHeight(@NonNull View underlyingObject) {
+                return mBubbleSizePx;
+            }
+
+            @Override
+            public void getLocationOnScreen(@NonNull View underlyingObject, @NonNull int[] loc) {
+                loc[0] = (int) bubble.getTranslationX();
+                loc[1] = (int) bubble.getTranslationY();
+            }
+        };
+        mMagnetizedBubbleDraggingOut.addTarget(target);
+        mMagnetizedBubbleDraggingOut.setMagnetListener(listener);
+        mMagnetizedBubbleDraggingOut.setHapticsEnabled(true);
+        mMagnetizedBubbleDraggingOut.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
+    }
+
+    private void springBubbleTo(View bubble, float x, float y) {
+        animationForChild(bubble)
+                .translationX(x)
+                .translationY(y)
+                .withStiffness(SpringForce.STIFFNESS_HIGH)
+                .start();
+    }
+
+    /**
+     * Drags an individual bubble to the given coordinates. Bubbles to the right will animate to
+     * take its place once it's dragged out of the row of bubbles, and animate out of the way if the
+     * bubble is dragged back into the row.
+     */
+    public void dragBubbleOut(View bubbleView, float x, float y) {
+        if (mSpringToTouchOnNextMotionEvent) {
+            springBubbleTo(mMagnetizedBubbleDraggingOut.getUnderlyingObject(), x, y);
+            mSpringToTouchOnNextMotionEvent = false;
+            mSpringingBubbleToTouch = true;
+        } else if (mSpringingBubbleToTouch) {
+            if (mLayout.arePropertiesAnimatingOnView(
+                    bubbleView, DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)) {
+                springBubbleTo(mMagnetizedBubbleDraggingOut.getUnderlyingObject(), x, y);
+            } else {
+                mSpringingBubbleToTouch = false;
+            }
+        }
+
+        if (!mSpringingBubbleToTouch && !mMagnetizedBubbleDraggingOut.getObjectStuckToTarget()) {
+            bubbleView.setTranslationX(x);
+            bubbleView.setTranslationY(y);
+        }
+
+        final boolean draggedOutEnough =
+                y > getExpandedY() + mBubbleSizePx || y < getExpandedY() - mBubbleSizePx;
+        if (draggedOutEnough != mBubbleDraggedOutEnough) {
+            updateBubblePositions();
+            mBubbleDraggedOutEnough = draggedOutEnough;
+        }
+    }
+
+    /** Plays a dismiss animation on the dragged out bubble. */
+    public void dismissDraggedOutBubble(View bubble, float translationYBy, Runnable after) {
+        if (bubble == null) {
+            return;
+        }
+        animationForChild(bubble)
+                .withStiffness(SpringForce.STIFFNESS_HIGH)
+                .scaleX(0f)
+                .scaleY(0f)
+                .translationY(bubble.getTranslationY() + translationYBy)
+                .alpha(0f, after)
+                .start();
+
+        updateBubblePositions();
+    }
+
+    @Nullable
+    public View getDraggedOutBubble() {
+        return mMagnetizedBubbleDraggingOut == null
+                ? null
+                : mMagnetizedBubbleDraggingOut.getUnderlyingObject();
+    }
+
+    /** Returns the MagnetizedObject instance for the dragging-out bubble. */
+    public MagnetizedObject<View> getMagnetizedBubbleDraggingOut() {
+        return mMagnetizedBubbleDraggingOut;
+    }
+
+    /**
+     * Snaps a bubble back to its position within the bubble row, and animates the rest of the
+     * bubbles to accommodate it if it was previously dragged out past the threshold.
+     */
+    public void snapBubbleBack(View bubbleView, float velX, float velY) {
+        final int index = mLayout.indexOfChild(bubbleView);
+
+        animationForChildAtIndex(index)
+                .position(getBubbleXOrYForOrientation(index), getExpandedY())
+                .withPositionStartVelocities(velX, velY)
+                .start(() -> bubbleView.setTranslationZ(0f) /* after */);
+
+        mMagnetizedBubbleDraggingOut = null;
+
+        updateBubblePositions();
+    }
+
+    /** Resets bubble drag out gesture flags. */
+    public void onGestureFinished() {
+        mBubbleDraggedOutEnough = false;
+        mMagnetizedBubbleDraggingOut = null;
+        updateBubblePositions();
+    }
+
+    /**
+     * Animates the bubbles to {@link #getExpandedY()} position. Used in response to IME showing.
+     */
+    public void updateYPosition(Runnable after) {
+        if (mLayout == null) return;
+        animationsForChildrenFromIndex(
+                0, (i, anim) -> anim.translationY(getExpandedY())).startAll(after);
+    }
+
+    /** The Y value of the row of expanded bubbles. */
+    public float getExpandedY() {
+        return mPositioner.getAvailableRect().top + mBubblePaddingTop;
+    }
+
+    /** Description of current animation controller state. */
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println("ExpandedAnimationController state:");
+        pw.print("  isActive:          "); pw.println(isActiveController());
+        pw.print("  animatingExpand:   "); pw.println(mAnimatingExpand);
+        pw.print("  animatingCollapse: "); pw.println(mAnimatingCollapse);
+        pw.print("  springingBubble:   "); pw.println(mSpringingBubbleToTouch);
+    }
+
+    @Override
+    void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
+        updateResources();
+
+        // Ensure that all child views are at 1x scale, and visible, in case they were animating
+        // in.
+        mLayout.setVisibility(View.VISIBLE);
+        animationsForChildrenFromIndex(0 /* startIndex */, (index, animation) ->
+                animation.scaleX(1f).scaleY(1f).alpha(1f)).startAll();
+    }
+
+    @Override
+    Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+        return Sets.newHashSet(
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y,
+                DynamicAnimation.SCALE_X,
+                DynamicAnimation.SCALE_Y,
+                DynamicAnimation.ALPHA);
+    }
+
+    @Override
+    int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+        return NONE;
+    }
+
+    @Override
+    float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+        return 0;
+    }
+
+    @Override
+    SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+        return new SpringForce()
+                .setDampingRatio(DAMPING_RATIO_MEDIUM_LOW_BOUNCY)
+                .setStiffness(SpringForce.STIFFNESS_LOW);
+    }
+
+    @Override
+    void onChildAdded(View child, int index) {
+        // If a bubble is added while the expand/collapse animations are playing, update the
+        // animation to include the new bubble.
+        if (mAnimatingExpand) {
+            startOrUpdatePathAnimation(true /* expanding */);
+        } else if (mAnimatingCollapse) {
+            startOrUpdatePathAnimation(false /* expanding */);
+        } else {
+            child.setTranslationX(getBubbleXOrYForOrientation(index));
+
+            // If we're preparing to collapse, don't start animations since the collapse animation
+            // will take over and animate the new bubble into the correct (stacked) position.
+            if (!mPreparingToCollapse) {
+                animationForChild(child)
+                        .translationY(
+                                getExpandedY()
+                                        - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR, /* from */
+                                getExpandedY() /* to */)
+                        .start();
+                updateBubblePositions();
+            }
+        }
+    }
+
+    @Override
+    void onChildRemoved(View child, int index, Runnable finishRemoval) {
+        // If we're removing the dragged-out bubble, that means it got dismissed.
+        if (child.equals(getDraggedOutBubble())) {
+            mMagnetizedBubbleDraggingOut = null;
+            finishRemoval.run();
+            mOnBubbleAnimatedOutAction.run();
+        } else {
+            PhysicsAnimator.getInstance(child)
+                    .spring(DynamicAnimation.ALPHA, 0f)
+                    .spring(DynamicAnimation.SCALE_X, 0f, mAnimateOutSpringConfig)
+                    .spring(DynamicAnimation.SCALE_Y, 0f, mAnimateOutSpringConfig)
+                    .withEndActions(finishRemoval, mOnBubbleAnimatedOutAction)
+                    .start();
+        }
+
+        // Animate all the other bubbles to their new positions sans this bubble.
+        updateBubblePositions();
+    }
+
+    @Override
+    void onChildReordered(View child, int oldIndex, int newIndex) {
+        if (mPreparingToCollapse) {
+            // If a re-order is received while we're preparing to collapse, ignore it. Once started,
+            // the collapse animation will animate all of the bubbles to their correct (stacked)
+            // position.
+            return;
+        }
+
+        if (mAnimatingCollapse) {
+            // If a re-order is received during collapse, update the animation so that the bubbles
+            // end up in the correct (stacked) position.
+            startOrUpdatePathAnimation(false /* expanding */);
+        } else {
+            // Otherwise, animate the bubbles around to reflect their new order.
+            updateBubblePositions();
+        }
+    }
+
+    private void updateBubblePositions() {
+        if (mAnimatingExpand || mAnimatingCollapse) {
+            return;
+        }
+
+        for (int i = 0; i < mLayout.getChildCount(); i++) {
+            final View bubble = mLayout.getChildAt(i);
+
+            // Don't animate the dragging out bubble, or it'll jump around while being dragged. It
+            // will be snapped to the correct X value after the drag (if it's not dismissed).
+            if (bubble.equals(getDraggedOutBubble())) {
+                return;
+            }
+
+            if (mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE) {
+                Rect availableRect = mPositioner.getAvailableRect();
+                boolean onLeft = mCollapsePoint != null
+                        && mCollapsePoint.x < (availableRect.width() / 2f);
+                animationForChild(bubble)
+                        .translationX(onLeft
+                                ? availableRect.left + mExpandedViewPadding
+                                : availableRect.right - mBubbleSizePx - mExpandedViewPadding)
+                        .translationY(getBubbleXOrYForOrientation(i))
+                        .start();
+            } else {
+                animationForChild(bubble)
+                        .translationX(getBubbleXOrYForOrientation(i))
+                        .translationY(getExpandedY())
+                        .start();
+            }
+        }
+    }
+
+    /**
+     * When bubbles are expanded in portrait, they display at the top of the screen in a horizontal
+     * row. When in landscape, they show at the left or right side in a vertical row. This method
+     * accounts for screen orientation and will return an x or y value for the position of the
+     * bubble in the row.
+     *
+     * @param index Bubble index in row.
+     * @return the y position of the bubble if {@link Configuration#ORIENTATION_LANDSCAPE} and the
+     * x position if {@link Configuration#ORIENTATION_PORTRAIT}.
+     */
+    public float getBubbleXOrYForOrientation(int index) {
+        if (mLayout == null) {
+            return 0;
+        }
+        Rect availableRect = mPositioner.getAvailableRect();
+        final boolean isLandscape =
+                mPositioner.getOrientation() == Configuration.ORIENTATION_LANDSCAPE;
+        final float availableSpace = isLandscape
+                ? availableRect.height()
+                : availableRect.width();
+        final float spaceForMaxBubbles = (mExpandedViewPadding * 2)
+                + (mBubblesMaxRendered + 1) * mBubbleSizePx;
+        final float spaceBetweenBubbles =
+                (availableSpace - spaceForMaxBubbles) / mBubblesMaxRendered;
+        final float expandedStackSize = (mLayout.getChildCount() * mBubbleSizePx)
+                + ((mLayout.getChildCount() - 1) * spaceBetweenBubbles);
+        final float centerPosition = isLandscape
+                ? availableRect.centerY()
+                : availableRect.centerX();
+        final float rowStart = centerPosition - (expandedStackSize / 2f);
+        final float positionInBar = index * (mBubbleSizePx + spaceBetweenBubbles);
+        return rowStart + positionInBar;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OneTimeEndListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OneTimeEndListener.java
new file mode 100644
index 0000000..37355c4
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OneTimeEndListener.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2019 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.wm.shell.bubbles.animation;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+
+/**
+ * End listener that removes itself from its animation when called for the first time. Useful since
+ * anonymous OnAnimationEndListener instances can't pass themselves to
+ * {@link DynamicAnimation#removeEndListener}, but can call through to this superclass
+ * implementation.
+ */
+public class OneTimeEndListener implements DynamicAnimation.OnAnimationEndListener {
+
+    @Override
+    public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+            float velocity) {
+        animation.removeEndListener(this);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayout.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayout.java
new file mode 100644
index 0000000..0618d5d
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayout.java
@@ -0,0 +1,1163 @@
+/*
+ * Copyright (C) 2019 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.wm.shell.bubbles.animation;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.animation.TimeInterpolator;
+import android.content.Context;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.util.FloatProperty;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewPropertyAnimator;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.R;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Layout that constructs physics-based animations for each of its children, which behave according
+ * to settings provided by a {@link PhysicsAnimationController} instance.
+ *
+ * See physics-animation-layout.md.
+ */
+public class PhysicsAnimationLayout extends FrameLayout {
+    private static final String TAG = "Bubbs.PAL";
+
+    /**
+     * Controls the construction, configuration, and use of the physics animations supplied by this
+     * layout.
+     */
+    abstract static class PhysicsAnimationController {
+
+        /** Configures a given {@link PhysicsPropertyAnimator} for a view at the given index. */
+        interface ChildAnimationConfigurator {
+
+            /**
+             * Called to configure the animator for the view at the given index.
+             *
+             * This method should make use of methods such as
+             * {@link PhysicsPropertyAnimator#translationX} and
+             * {@link PhysicsPropertyAnimator#withStartDelay} to configure the animation.
+             *
+             * Implementations should not call {@link PhysicsPropertyAnimator#start}, this will
+             * happen elsewhere after configuration is complete.
+             */
+            void configureAnimationForChildAtIndex(int index, PhysicsPropertyAnimator animation);
+        }
+
+        /**
+         * Returned by {@link #animationsForChildrenFromIndex} to allow starting multiple animations
+         * on multiple child views at the same time.
+         */
+        interface MultiAnimationStarter {
+
+            /**
+             * Start all animations and call the given end actions once all animations have
+             * completed.
+             */
+            void startAll(Runnable... endActions);
+        }
+
+        /**
+         * Constant to return from {@link #getNextAnimationInChain} if the animation should not be
+         * chained at all.
+         */
+        protected static final int NONE = -1;
+
+        /** Set of properties for which the layout should construct physics animations. */
+        abstract Set<DynamicAnimation.ViewProperty> getAnimatedProperties();
+
+        /**
+         * Returns the index of the next animation after the given index in the animation chain, or
+         * {@link #NONE} if it should not be chained, or if the chain should end at the given index.
+         *
+         * If a next index is returned, an update listener will be added to the animation at the
+         * given index that dispatches value updates to the animation at the next index. This
+         * creates a 'following' effect.
+         *
+         * Typical implementations of this method will return either index + 1, or index - 1, to
+         * create forward or backward chains between adjacent child views, but this is not required.
+         */
+        abstract int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index);
+
+        /**
+         * Offsets to be added to the value that chained animations of the given property dispatch
+         * to subsequent child animations.
+         *
+         * This is used for things like maintaining the 'stack' effect in Bubbles, where bubbles
+         * stack off to the left or right side slightly.
+         */
+        abstract float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property);
+
+        /**
+         * Returns the SpringForce to be used for the given child view's property animation. Despite
+         * these usually being similar or identical across properties and views, {@link SpringForce}
+         * also contains the SpringAnimation's final position, so we have to construct a new one for
+         * each animation rather than using a constant.
+         */
+        abstract SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view);
+
+        /**
+         * Called when a new child is added at the specified index. Controllers can use this
+         * opportunity to animate in the new view.
+         */
+        abstract void onChildAdded(View child, int index);
+
+        /**
+         * Called with a child view that has been removed from the layout, from the given index. The
+         * passed view has been removed from the layout and added back as a transient view, which
+         * renders normally, but is not part of the normal view hierarchy and will not be considered
+         * by getChildAt() and getChildCount().
+         *
+         * The controller can perform animations on the child (either manually, or by using
+         * {@link #animationForChild(View)}), and then call finishRemoval when complete.
+         *
+         * finishRemoval must be called by implementations of this method, or transient views will
+         * never be removed.
+         */
+        abstract void onChildRemoved(View child, int index, Runnable finishRemoval);
+
+        /** Called when a child view has been reordered in the view hierachy. */
+        abstract void onChildReordered(View child, int oldIndex, int newIndex);
+
+        /**
+         * Called when the controller is set as the active animation controller for the given
+         * layout. Once active, the controller can start animations using the animator instances
+         * returned by {@link #animationForChild}.
+         *
+         * While all animations started by the previous controller will be cancelled, the new
+         * controller should not make any assumptions about the state of the layout or its children.
+         * Their translation, alpha, scale, etc. values may have been changed by the previous
+         * controller and should be reset here if relevant.
+         */
+        abstract void onActiveControllerForLayout(PhysicsAnimationLayout layout);
+
+        protected PhysicsAnimationLayout mLayout;
+
+        PhysicsAnimationController() { }
+
+        /** Whether this controller is the currently active controller for its associated layout. */
+        protected boolean isActiveController() {
+            return mLayout != null && this == mLayout.mController;
+        }
+
+        protected void setLayout(PhysicsAnimationLayout layout) {
+            this.mLayout = layout;
+            onActiveControllerForLayout(layout);
+        }
+
+        protected PhysicsAnimationLayout getLayout() {
+            return mLayout;
+        }
+
+        /**
+         * Returns a {@link PhysicsPropertyAnimator} instance for the given child view.
+         */
+        protected PhysicsPropertyAnimator animationForChild(View child) {
+            PhysicsPropertyAnimator animator =
+                    (PhysicsPropertyAnimator) child.getTag(R.id.physics_animator_tag);
+
+            if (animator == null) {
+                animator = mLayout.new PhysicsPropertyAnimator(child);
+                child.setTag(R.id.physics_animator_tag, animator);
+            }
+
+            animator.clearAnimator();
+            animator.setAssociatedController(this);
+
+            return animator;
+        }
+
+        /** Returns a {@link PhysicsPropertyAnimator} instance for child at the given index. */
+        protected PhysicsPropertyAnimator animationForChildAtIndex(int index) {
+            return animationForChild(mLayout.getChildAt(index));
+        }
+
+        /**
+         * Returns a {@link MultiAnimationStarter} whose startAll method will start the physics
+         * animations for all children from startIndex onward. The provided configurator will be
+         * called with each child's {@link PhysicsPropertyAnimator}, where it can set up each
+         * animation appropriately.
+         */
+        protected MultiAnimationStarter animationsForChildrenFromIndex(
+                int startIndex, ChildAnimationConfigurator configurator) {
+            final Set<DynamicAnimation.ViewProperty> allAnimatedProperties = new HashSet<>();
+            final List<PhysicsPropertyAnimator> allChildAnims = new ArrayList<>();
+
+            // Retrieve the animator for each child, ask the configurator to configure it, then save
+            // it and the properties it chose to animate.
+            for (int i = startIndex; i < mLayout.getChildCount(); i++) {
+                final PhysicsPropertyAnimator anim = animationForChildAtIndex(i);
+                configurator.configureAnimationForChildAtIndex(i, anim);
+                allAnimatedProperties.addAll(anim.getAnimatedProperties());
+                allChildAnims.add(anim);
+            }
+
+            // Return a MultiAnimationStarter that will start all of the child animations, and also
+            // add a multiple property end listener to the layout that will call the end action
+            // provided to startAll() once all animations on the animated properties complete.
+            return (endActions) -> {
+                final Runnable runAllEndActions = () -> {
+                    for (Runnable action : endActions) {
+                        action.run();
+                    }
+                };
+
+                // If there aren't any children to animate, just run the end actions.
+                if (mLayout.getChildCount() == 0) {
+                    runAllEndActions.run();
+                    return;
+                }
+
+                if (endActions != null) {
+                    setEndActionForMultipleProperties(
+                            runAllEndActions,
+                            allAnimatedProperties.toArray(
+                                    new DynamicAnimation.ViewProperty[0]));
+                }
+
+                for (PhysicsPropertyAnimator childAnim : allChildAnims) {
+                    childAnim.start();
+                }
+            };
+        }
+
+        /**
+         * Sets an end action that will be run when all child animations for a given property have
+         * stopped running.
+         */
+        protected void setEndActionForProperty(
+                Runnable action, DynamicAnimation.ViewProperty property) {
+            mLayout.mEndActionForProperty.put(property, action);
+        }
+
+        /**
+         * Sets an end action that will be run when all child animations for all of the given
+         * properties have stopped running.
+         */
+        protected void setEndActionForMultipleProperties(
+                Runnable action, DynamicAnimation.ViewProperty... properties) {
+            final Runnable checkIfAllFinished = () -> {
+                if (!mLayout.arePropertiesAnimating(properties)) {
+                    action.run();
+
+                    for (DynamicAnimation.ViewProperty property : properties) {
+                        removeEndActionForProperty(property);
+                    }
+                }
+            };
+
+            for (DynamicAnimation.ViewProperty property : properties) {
+                setEndActionForProperty(checkIfAllFinished, property);
+            }
+        }
+
+        /**
+         * Removes the end listener that would have been called when all child animations for a
+         * given property stopped running.
+         */
+        protected void removeEndActionForProperty(DynamicAnimation.ViewProperty property) {
+            mLayout.mEndActionForProperty.remove(property);
+        }
+    }
+
+    /**
+     * End actions that are called when every child's animation of the given property has finished.
+     */
+    protected final HashMap<DynamicAnimation.ViewProperty, Runnable> mEndActionForProperty =
+            new HashMap<>();
+
+    /** The currently active animation controller. */
+    @Nullable protected PhysicsAnimationController mController;
+
+    public PhysicsAnimationLayout(Context context) {
+        super(context);
+    }
+
+    /**
+     * Sets the animation controller and constructs or reconfigures the layout's physics animations
+     * to meet the controller's specifications.
+     */
+    public void setActiveController(PhysicsAnimationController controller) {
+        cancelAllAnimations();
+        mEndActionForProperty.clear();
+
+        this.mController = controller;
+        mController.setLayout(this);
+
+        // Set up animations for this controller's animated properties.
+        for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+            setUpAnimationsForProperty(property);
+        }
+    }
+
+    @Override
+    public void addView(View child, int index, ViewGroup.LayoutParams params) {
+        addViewInternal(child, index, params, false /* isReorder */);
+    }
+
+    @Override
+    public void removeView(View view) {
+        if (mController != null) {
+            final int index = indexOfChild(view);
+
+            // Remove the view and add it back as a transient view so we can animate it out.
+            super.removeView(view);
+            addTransientView(view, index);
+
+            // Tell the controller to animate this view out, and call the callback when it's
+            // finished.
+            mController.onChildRemoved(view, index, () -> {
+                // The controller says it's done with the transient view, cancel animations in case
+                // any are still running and then remove it.
+                cancelAnimationsOnView(view);
+                removeTransientView(view);
+            });
+        } else {
+            // Without a controller, nobody will animate this view out, so it gets an unceremonious
+            // departure.
+            super.removeView(view);
+        }
+    }
+
+    @Override
+    public void removeViewAt(int index) {
+        removeView(getChildAt(index));
+    }
+
+    /** Immediately re-orders the view to the given index. */
+    public void reorderView(View view, int index) {
+        if (view == null) {
+            return;
+        }
+        final int oldIndex = indexOfChild(view);
+
+        super.removeView(view);
+        addViewInternal(view, index, view.getLayoutParams(), true /* isReorder */);
+
+        if (mController != null) {
+            mController.onChildReordered(view, oldIndex, index);
+        }
+    }
+
+    /** Checks whether any animations of the given properties are still running. */
+    public boolean arePropertiesAnimating(DynamicAnimation.ViewProperty... properties) {
+        for (int i = 0; i < getChildCount(); i++) {
+            if (arePropertiesAnimatingOnView(getChildAt(i), properties)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /** Checks whether any animations of the given properties are running on the given view. */
+    public boolean arePropertiesAnimatingOnView(
+            View view, DynamicAnimation.ViewProperty... properties) {
+        final ObjectAnimator targetAnimator = getTargetAnimatorFromView(view);
+        for (DynamicAnimation.ViewProperty property : properties) {
+            final SpringAnimation animation = getSpringAnimationFromView(property, view);
+            if (animation != null && animation.isRunning()) {
+                return true;
+            }
+
+            // If the target animator is running, its update listener will trigger the translation
+            // physics animations at some point. We should consider the translation properties to be
+            // be animating in this case, even if the physics animations haven't been started yet.
+            final boolean isTranslation =
+                    property.equals(DynamicAnimation.TRANSLATION_X)
+                            || property.equals(DynamicAnimation.TRANSLATION_Y);
+            if (isTranslation && targetAnimator != null && targetAnimator.isRunning()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /** Cancels all animations that are running on all child views, for all properties. */
+    public void cancelAllAnimations() {
+        if (mController == null) {
+            return;
+        }
+
+        cancelAllAnimationsOfProperties(
+                mController.getAnimatedProperties().toArray(new DynamicAnimation.ViewProperty[]{}));
+    }
+
+    /** Cancels all animations that are running on all child views, for the given properties. */
+    public void cancelAllAnimationsOfProperties(DynamicAnimation.ViewProperty... properties) {
+        if (mController == null) {
+            return;
+        }
+
+        for (int i = 0; i < getChildCount(); i++) {
+            for (DynamicAnimation.ViewProperty property : properties) {
+                final DynamicAnimation anim = getSpringAnimationAtIndex(property, i);
+                if (anim != null) {
+                    anim.cancel();
+                }
+            }
+            final ViewPropertyAnimator anim = getViewPropertyAnimatorFromView(getChildAt(i));
+            if (anim != null) {
+                anim.cancel();
+            }
+        }
+    }
+
+    /** Cancels all of the physics animations running on the given view. */
+    public void cancelAnimationsOnView(View view) {
+        // If present, cancel the target animator so it doesn't restart the translation physics
+        // animations.
+        final ObjectAnimator targetAnimator = getTargetAnimatorFromView(view);
+        if (targetAnimator != null) {
+            targetAnimator.cancel();
+        }
+
+        // Cancel physics animations on the view.
+        for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+            final DynamicAnimation animationFromView = getSpringAnimationFromView(property, view);
+            if (animationFromView != null) {
+                animationFromView.cancel();
+            }
+        }
+    }
+
+    protected boolean isActiveController(PhysicsAnimationController controller) {
+        return mController == controller;
+    }
+
+    /** Whether the first child would be left of center if translated to the given x value. */
+    protected boolean isFirstChildXLeftOfCenter(float x) {
+        if (getChildCount() > 0) {
+            return x + (getChildAt(0).getWidth() / 2) < getWidth() / 2;
+        } else {
+            return false; // If there's no first child, really anything is correct, right?
+        }
+    }
+
+    /** ViewProperty's toString is useless, this returns a readable name for debug logging. */
+    protected static String getReadablePropertyName(DynamicAnimation.ViewProperty property) {
+        if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+            return "TRANSLATION_X";
+        } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+            return "TRANSLATION_Y";
+        } else if (property.equals(DynamicAnimation.SCALE_X)) {
+            return "SCALE_X";
+        } else if (property.equals(DynamicAnimation.SCALE_Y)) {
+            return "SCALE_Y";
+        } else if (property.equals(DynamicAnimation.ALPHA)) {
+            return "ALPHA";
+        } else {
+            return "Unknown animation property.";
+        }
+    }
+
+    /**
+     * Adds a view to the layout. If this addition is not the result of a call to
+     * {@link #reorderView}, this will also notify the controller via
+     * {@link PhysicsAnimationController#onChildAdded} and set up animations for the view.
+     */
+    private void addViewInternal(
+            View child, int index, ViewGroup.LayoutParams params, boolean isReorder) {
+        super.addView(child, index, params);
+
+        // Set up animations for the new view, if the controller is set. If it isn't set, we'll be
+        // setting up animations for all children when setActiveController is called.
+        if (mController != null && !isReorder) {
+            for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+                setUpAnimationForChild(property, child, index);
+            }
+
+            mController.onChildAdded(child, index);
+        }
+    }
+
+    /**
+     * Retrieves the animation of the given property from the view at the given index via the view
+     * tag system.
+     */
+    @Nullable private SpringAnimation getSpringAnimationAtIndex(
+            DynamicAnimation.ViewProperty property, int index) {
+        return getSpringAnimationFromView(property, getChildAt(index));
+    }
+
+    /**
+     * Retrieves the spring animation of the given property from the view via the view tag system.
+     */
+    @Nullable private SpringAnimation getSpringAnimationFromView(
+            DynamicAnimation.ViewProperty property, View view) {
+        return (SpringAnimation) view.getTag(getTagIdForProperty(property));
+    }
+
+    /**
+     * Retrieves the view property animation of the given property from the view via the view tag
+     * system.
+     */
+    @Nullable private ViewPropertyAnimator getViewPropertyAnimatorFromView(View view) {
+        return (ViewPropertyAnimator) view.getTag(R.id.reorder_animator_tag);
+    }
+
+    /** Retrieves the target animator from the view via the view tag system. */
+    @Nullable private ObjectAnimator getTargetAnimatorFromView(View view) {
+        return (ObjectAnimator) view.getTag(R.id.target_animator_tag);
+    }
+
+    /** Sets up SpringAnimations of the given property for each child view in the layout. */
+    private void setUpAnimationsForProperty(DynamicAnimation.ViewProperty property) {
+        for (int i = 0; i < getChildCount(); i++) {
+            setUpAnimationForChild(property, getChildAt(i), i);
+        }
+    }
+
+    /** Constructs a SpringAnimation of the given property for a child view. */
+    private void setUpAnimationForChild(
+            DynamicAnimation.ViewProperty property, View child, int index) {
+        SpringAnimation newAnim = new SpringAnimation(child, property);
+        newAnim.addUpdateListener((animation, value, velocity) -> {
+            final int indexOfChild = indexOfChild(child);
+            final int nextAnimInChain = mController.getNextAnimationInChain(property, indexOfChild);
+
+            if (nextAnimInChain == PhysicsAnimationController.NONE || indexOfChild < 0) {
+                return;
+            }
+
+            final float offset = mController.getOffsetForChainedPropertyAnimation(property);
+            if (nextAnimInChain < getChildCount()) {
+                final SpringAnimation nextAnim = getSpringAnimationAtIndex(
+                        property, nextAnimInChain);
+                if (nextAnim != null) {
+                    nextAnim.animateToFinalPosition(value + offset);
+                }
+            }
+        });
+
+        newAnim.setSpring(mController.getSpringForce(property, child));
+        newAnim.addEndListener(new AllAnimationsForPropertyFinishedEndListener(property));
+        child.setTag(getTagIdForProperty(property), newAnim);
+    }
+
+    /** Return a stable ID to use as a tag key for the given property's animations. */
+    private int getTagIdForProperty(DynamicAnimation.ViewProperty property) {
+        if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+            return R.id.translation_x_dynamicanimation_tag;
+        } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+            return R.id.translation_y_dynamicanimation_tag;
+        } else if (property.equals(DynamicAnimation.SCALE_X)) {
+            return R.id.scale_x_dynamicanimation_tag;
+        } else if (property.equals(DynamicAnimation.SCALE_Y)) {
+            return R.id.scale_y_dynamicanimation_tag;
+        } else if (property.equals(DynamicAnimation.ALPHA)) {
+            return R.id.alpha_dynamicanimation_tag;
+        }
+
+        return -1;
+    }
+
+    /**
+     * End listener that is added to each individual DynamicAnimation, which dispatches to a single
+     * listener when every other animation of the given property is no longer running.
+     *
+     * This is required since chained DynamicAnimations can stop and start again due to changes in
+     * upstream animations. This means that adding an end listener to just the last animation is not
+     * sufficient. By firing only when every other animation on the property has stopped running, we
+     * ensure that no animation will be restarted after the single end listener is called.
+     */
+    protected class AllAnimationsForPropertyFinishedEndListener
+            implements DynamicAnimation.OnAnimationEndListener {
+        private DynamicAnimation.ViewProperty mProperty;
+
+        AllAnimationsForPropertyFinishedEndListener(DynamicAnimation.ViewProperty property) {
+            this.mProperty = property;
+        }
+
+        @Override
+        public void onAnimationEnd(
+                DynamicAnimation anim, boolean canceled, float value, float velocity) {
+            if (!arePropertiesAnimating(mProperty)) {
+                if (mEndActionForProperty.containsKey(mProperty)) {
+                    final Runnable callback = mEndActionForProperty.get(mProperty);
+
+                    if (callback != null) {
+                        callback.run();
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Animator class returned by {@link PhysicsAnimationController#animationForChild}, to allow
+     * controllers to animate child views using physics animations.
+     *
+     * See docs/physics-animation-layout.md for documentation and examples.
+     */
+    protected class PhysicsPropertyAnimator {
+        /** The view whose properties this animator animates. */
+        private View mView;
+
+        /** Start velocity to use for all property animations. */
+        private float mDefaultStartVelocity = -Float.MAX_VALUE;
+
+        /** Start delay to use when start is called. */
+        private long mStartDelay = 0;
+
+        /** Damping ratio to use for the animations. */
+        private float mDampingRatio = -1;
+
+        /** Stiffness to use for the animations. */
+        private float mStiffness = -1;
+
+        /** End actions to call when animations for the given property complete. */
+        private Map<DynamicAnimation.ViewProperty, Runnable[]> mEndActionsForProperty =
+                new HashMap<>();
+
+        /**
+         * Start velocities to use for TRANSLATION_X and TRANSLATION_Y, since these are often
+         * provided by VelocityTrackers and differ from each other.
+         */
+        private Map<DynamicAnimation.ViewProperty, Float> mPositionStartVelocities =
+                new HashMap<>();
+
+        /**
+         * End actions to call when both TRANSLATION_X and TRANSLATION_Y animations have completed,
+         * if {@link #position} was used to animate TRANSLATION_X and TRANSLATION_Y simultaneously.
+         */
+        @Nullable private Runnable[] mPositionEndActions;
+
+        /**
+         * All of the properties that have been set and will animate when {@link #start} is called.
+         */
+        private Map<DynamicAnimation.ViewProperty, Float> mAnimatedProperties = new HashMap<>();
+
+        /**
+         * All of the initial property values that have been set. These values will be instantly set
+         * when {@link #start} is called, just before the animation begins.
+         */
+        private Map<DynamicAnimation.ViewProperty, Float> mInitialPropertyValues = new HashMap<>();
+
+        /** The animation controller that last retrieved this animator instance. */
+        private PhysicsAnimationController mAssociatedController;
+
+        /**
+         * Animator used to traverse the path provided to {@link #followAnimatedTargetAlongPath}. As
+         * the path is traversed, the view's translation spring animation final positions are
+         * updated such that the view 'follows' the current position on the path.
+         */
+        @Nullable private ObjectAnimator mPathAnimator;
+
+        /** Current position on the path. This is animated by {@link #mPathAnimator}. */
+        private PointF mCurrentPointOnPath = new PointF();
+
+        /**
+         * FloatProperty instances that can be passed to {@link ObjectAnimator} to animate the value
+         * of {@link #mCurrentPointOnPath}.
+         */
+        private final FloatProperty<PhysicsPropertyAnimator> mCurrentPointOnPathXProperty =
+                new FloatProperty<PhysicsPropertyAnimator>("PathX") {
+            @Override
+            public void setValue(PhysicsPropertyAnimator object, float value) {
+                mCurrentPointOnPath.x = value;
+            }
+
+            @Override
+            public Float get(PhysicsPropertyAnimator object) {
+                return mCurrentPointOnPath.x;
+            }
+        };
+
+        private final FloatProperty<PhysicsPropertyAnimator> mCurrentPointOnPathYProperty =
+                new FloatProperty<PhysicsPropertyAnimator>("PathY") {
+            @Override
+            public void setValue(PhysicsPropertyAnimator object, float value) {
+                mCurrentPointOnPath.y = value;
+            }
+
+            @Override
+            public Float get(PhysicsPropertyAnimator object) {
+                return mCurrentPointOnPath.y;
+            }
+        };
+
+        protected PhysicsPropertyAnimator(View view) {
+            this.mView = view;
+        }
+
+        /** Animate a property to the given value, then call the optional end actions. */
+        public PhysicsPropertyAnimator property(
+                DynamicAnimation.ViewProperty property, float value, Runnable... endActions) {
+            mAnimatedProperties.put(property, value);
+            mEndActionsForProperty.put(property, endActions);
+            return this;
+        }
+
+        /** Animate the view's alpha value to the provided value. */
+        public PhysicsPropertyAnimator alpha(float alpha, Runnable... endActions) {
+            return property(DynamicAnimation.ALPHA, alpha, endActions);
+        }
+
+        /** Set the view's alpha value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator alpha(float from, float to, Runnable... endActions) {
+            mInitialPropertyValues.put(DynamicAnimation.ALPHA, from);
+            return alpha(to, endActions);
+        }
+
+        /** Animate the view's translationX value to the provided value. */
+        public PhysicsPropertyAnimator translationX(float translationX, Runnable... endActions) {
+            mPathAnimator = null; // We aren't using the path anymore if we're translating.
+            return property(DynamicAnimation.TRANSLATION_X, translationX, endActions);
+        }
+
+        /** Set the view's translationX value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator translationX(
+                float from, float to, Runnable... endActions) {
+            mInitialPropertyValues.put(DynamicAnimation.TRANSLATION_X, from);
+            return translationX(to, endActions);
+        }
+
+        /** Animate the view's translationY value to the provided value. */
+        public PhysicsPropertyAnimator translationY(float translationY, Runnable... endActions) {
+            mPathAnimator = null; // We aren't using the path anymore if we're translating.
+            return property(DynamicAnimation.TRANSLATION_Y, translationY, endActions);
+        }
+
+        /** Set the view's translationY value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator translationY(
+                float from, float to, Runnable... endActions) {
+            mInitialPropertyValues.put(DynamicAnimation.TRANSLATION_Y, from);
+            return translationY(to, endActions);
+        }
+
+        /**
+         * Animate the view's translationX and translationY values, and call the end actions only
+         * once both TRANSLATION_X and TRANSLATION_Y animations have completed.
+         */
+        public PhysicsPropertyAnimator position(
+                float translationX, float translationY, Runnable... endActions) {
+            mPositionEndActions = endActions;
+            translationX(translationX);
+            return translationY(translationY);
+        }
+
+        /**
+         * Animates a 'target' point that moves along the given path, using the provided duration
+         * and interpolator to animate the target. The view itself is animated using physics-based
+         * animations, whose final positions are updated to the target position as it animates. This
+         * results in the view 'following' the target in a realistic way.
+         *
+         * This method will override earlier calls to {@link #translationX}, {@link #translationY},
+         * or {@link #position}, ultimately animating the view's position to the final point on the
+         * given path.
+         *
+         * @param pathAnimEndActions End actions to run after the animator that moves the target
+         *                           along the path ends. The views following the target may still
+         *                           be moving.
+         */
+        public PhysicsPropertyAnimator followAnimatedTargetAlongPath(
+                Path path,
+                int targetAnimDuration,
+                TimeInterpolator targetAnimInterpolator,
+                Runnable... pathAnimEndActions) {
+            if (mPathAnimator != null) {
+                mPathAnimator.cancel();
+            }
+
+            mPathAnimator = ObjectAnimator.ofFloat(
+                    this, mCurrentPointOnPathXProperty, mCurrentPointOnPathYProperty, path);
+
+            if (pathAnimEndActions != null) {
+                mPathAnimator.addListener(new AnimatorListenerAdapter() {
+                    @Override
+                    public void onAnimationEnd(Animator animation) {
+                        for (Runnable action : pathAnimEndActions) {
+                            if (action != null) {
+                                action.run();
+                            }
+                        }
+                    }
+                });
+            }
+
+            mPathAnimator.setDuration(targetAnimDuration);
+            mPathAnimator.setInterpolator(targetAnimInterpolator);
+
+            // Remove translation related values since we're going to ignore them and follow the
+            // path instead.
+            clearTranslationValues();
+            return this;
+        }
+
+        private void clearTranslationValues() {
+            mAnimatedProperties.remove(DynamicAnimation.TRANSLATION_X);
+            mAnimatedProperties.remove(DynamicAnimation.TRANSLATION_Y);
+            mInitialPropertyValues.remove(DynamicAnimation.TRANSLATION_X);
+            mInitialPropertyValues.remove(DynamicAnimation.TRANSLATION_Y);
+            mEndActionForProperty.remove(DynamicAnimation.TRANSLATION_X);
+            mEndActionForProperty.remove(DynamicAnimation.TRANSLATION_Y);
+        }
+
+        /** Animate the view's scaleX value to the provided value. */
+        public PhysicsPropertyAnimator scaleX(float scaleX, Runnable... endActions) {
+            return property(DynamicAnimation.SCALE_X, scaleX, endActions);
+        }
+
+        /** Set the view's scaleX value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator scaleX(float from, float to, Runnable... endActions) {
+            mInitialPropertyValues.put(DynamicAnimation.SCALE_X, from);
+            return scaleX(to, endActions);
+        }
+
+        /** Animate the view's scaleY value to the provided value. */
+        public PhysicsPropertyAnimator scaleY(float scaleY, Runnable... endActions) {
+            return property(DynamicAnimation.SCALE_Y, scaleY, endActions);
+        }
+
+        /** Set the view's scaleY value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator scaleY(float from, float to, Runnable... endActions) {
+            mInitialPropertyValues.put(DynamicAnimation.SCALE_Y, from);
+            return scaleY(to, endActions);
+        }
+
+        /** Set the start velocity to use for all property animations. */
+        public PhysicsPropertyAnimator withStartVelocity(float startVel) {
+            mDefaultStartVelocity = startVel;
+            return this;
+        }
+
+        /**
+         * Set the damping ratio to use for this animation. If not supplied, will default to the
+         * value from {@link PhysicsAnimationController#getSpringForce}.
+         */
+        public PhysicsPropertyAnimator withDampingRatio(float dampingRatio) {
+            mDampingRatio = dampingRatio;
+            return this;
+        }
+
+        /**
+         * Set the stiffness to use for this animation. If not supplied, will default to the
+         * value from {@link PhysicsAnimationController#getSpringForce}.
+         */
+        public PhysicsPropertyAnimator withStiffness(float stiffness) {
+            mStiffness = stiffness;
+            return this;
+        }
+
+        /**
+         * Set the start velocities to use for TRANSLATION_X and TRANSLATION_Y animations. This
+         * overrides any value set via {@link #withStartVelocity(float)} for those properties.
+         */
+        public PhysicsPropertyAnimator withPositionStartVelocities(float velX, float velY) {
+            mPositionStartVelocities.put(DynamicAnimation.TRANSLATION_X, velX);
+            mPositionStartVelocities.put(DynamicAnimation.TRANSLATION_Y, velY);
+            return this;
+        }
+
+        /** Set a delay, in milliseconds, before kicking off the animations. */
+        public PhysicsPropertyAnimator withStartDelay(long startDelay) {
+            mStartDelay = startDelay;
+            return this;
+        }
+
+        /**
+         * Start the animations, and call the optional end actions once all animations for every
+         * animated property on every child (including chained animations) have ended.
+         */
+        public void start(Runnable... after) {
+            if (!isActiveController(mAssociatedController)) {
+                Log.w(TAG, "Only the active animation controller is allowed to start animations. "
+                        + "Use PhysicsAnimationLayout#setActiveController to set the active "
+                        + "animation controller.");
+                return;
+            }
+
+            final Set<DynamicAnimation.ViewProperty> properties = getAnimatedProperties();
+
+            // If there are end actions, set an end listener on the layout for all the properties
+            // we're about to animate.
+            if (after != null && after.length > 0) {
+                final DynamicAnimation.ViewProperty[] propertiesArray =
+                        properties.toArray(new DynamicAnimation.ViewProperty[0]);
+                mAssociatedController.setEndActionForMultipleProperties(() -> {
+                    for (Runnable callback : after) {
+                        callback.run();
+                    }
+                }, propertiesArray);
+            }
+
+            // If we used position-specific end actions, we'll need to listen for both TRANSLATION_X
+            // and TRANSLATION_Y animations ending, and call them once both have finished.
+            if (mPositionEndActions != null) {
+                final SpringAnimation translationXAnim =
+                        getSpringAnimationFromView(DynamicAnimation.TRANSLATION_X, mView);
+                final SpringAnimation translationYAnim =
+                        getSpringAnimationFromView(DynamicAnimation.TRANSLATION_Y, mView);
+                final Runnable waitForBothXAndY = () -> {
+                    if (!translationXAnim.isRunning() && !translationYAnim.isRunning()) {
+                        if (mPositionEndActions != null) {
+                            for (Runnable callback : mPositionEndActions) {
+                                callback.run();
+                            }
+                        }
+
+                        mPositionEndActions = null;
+                    }
+                };
+
+                mEndActionsForProperty.put(DynamicAnimation.TRANSLATION_X,
+                        new Runnable[]{waitForBothXAndY});
+                mEndActionsForProperty.put(DynamicAnimation.TRANSLATION_Y,
+                        new Runnable[]{waitForBothXAndY});
+            }
+
+            if (mPathAnimator != null) {
+                startPathAnimation();
+            }
+
+            // Actually start the animations.
+            for (DynamicAnimation.ViewProperty property : properties) {
+                // Don't start translation animations if we're using a path animator, the update
+                // listeners added to that animator will take care of that.
+                if (mPathAnimator != null
+                        && (property.equals(DynamicAnimation.TRANSLATION_X)
+                            || property.equals(DynamicAnimation.TRANSLATION_Y))) {
+                    return;
+                }
+
+                if (mInitialPropertyValues.containsKey(property)) {
+                    property.setValue(mView, mInitialPropertyValues.get(property));
+                }
+
+                final SpringForce defaultSpringForce = mController.getSpringForce(property, mView);
+                animateValueForChild(
+                        property,
+                        mView,
+                        mAnimatedProperties.get(property),
+                        mPositionStartVelocities.getOrDefault(property, mDefaultStartVelocity),
+                        mStartDelay,
+                        mStiffness >= 0 ? mStiffness : defaultSpringForce.getStiffness(),
+                        mDampingRatio >= 0 ? mDampingRatio : defaultSpringForce.getDampingRatio(),
+                        mEndActionsForProperty.get(property));
+            }
+
+            clearAnimator();
+        }
+
+        /** Returns the set of properties that will animate once {@link #start} is called. */
+        protected Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+            final HashSet<DynamicAnimation.ViewProperty> animatedProperties = new HashSet<>(
+                    mAnimatedProperties.keySet());
+
+            // If we're using a path animator, it'll kick off translation animations.
+            if (mPathAnimator != null) {
+                animatedProperties.add(DynamicAnimation.TRANSLATION_X);
+                animatedProperties.add(DynamicAnimation.TRANSLATION_Y);
+            }
+
+            return animatedProperties;
+        }
+
+        /**
+         * Animates the property of the given child view, then runs the callback provided when the
+         * animation ends.
+         */
+        protected void animateValueForChild(
+                DynamicAnimation.ViewProperty property,
+                View view,
+                float value,
+                float startVel,
+                long startDelay,
+                float stiffness,
+                float dampingRatio,
+                Runnable... afterCallbacks) {
+            if (view != null) {
+                final SpringAnimation animation =
+                        (SpringAnimation) view.getTag(getTagIdForProperty(property));
+
+                // If the animation is null, the view was probably removed from the layout before
+                // the animation started.
+                if (animation == null) {
+                    return;
+                }
+
+                if (afterCallbacks != null) {
+                    animation.addEndListener(new OneTimeEndListener() {
+                        @Override
+                        public void onAnimationEnd(DynamicAnimation animation, boolean canceled,
+                                float value, float velocity) {
+                            super.onAnimationEnd(animation, canceled, value, velocity);
+                            for (Runnable runnable : afterCallbacks) {
+                                runnable.run();
+                            }
+                        }
+                    });
+                }
+
+                final SpringForce animationSpring = animation.getSpring();
+
+                if (animationSpring == null) {
+                    return;
+                }
+
+                final Runnable configureAndStartAnimation = () -> {
+                    animationSpring.setStiffness(stiffness);
+                    animationSpring.setDampingRatio(dampingRatio);
+
+                    if (startVel > -Float.MAX_VALUE) {
+                        animation.setStartVelocity(startVel);
+                    }
+
+                    animationSpring.setFinalPosition(value);
+                    animation.start();
+                };
+
+                if (startDelay > 0) {
+                    postDelayed(configureAndStartAnimation, startDelay);
+                } else {
+                    configureAndStartAnimation.run();
+                }
+            }
+        }
+
+        /**
+         * Updates the final position of a view's animation, without changing any of the animation's
+         * other settings. Calling this before an initial call to {@link #animateValueForChild} will
+         * work, but result in unknown values for stiffness, etc. and is not recommended.
+         */
+        private void updateValueForChild(
+                DynamicAnimation.ViewProperty property, View view, float position) {
+            if (view != null) {
+                final SpringAnimation animation =
+                        (SpringAnimation) view.getTag(getTagIdForProperty(property));
+
+                if (animation == null) {
+                    return;
+                }
+
+                final SpringForce animationSpring = animation.getSpring();
+
+                if (animationSpring == null) {
+                    return;
+                }
+
+                animationSpring.setFinalPosition(position);
+                animation.start();
+            }
+        }
+
+        /**
+         * Configures the path animator to respect the settings passed into the animation builder
+         * and adds update listeners that update the translation physics animations. Then, starts
+         * the path animation.
+         */
+        protected void startPathAnimation() {
+            final SpringForce defaultSpringForceX = mController.getSpringForce(
+                    DynamicAnimation.TRANSLATION_X, mView);
+            final SpringForce defaultSpringForceY = mController.getSpringForce(
+                    DynamicAnimation.TRANSLATION_Y, mView);
+
+            if (mStartDelay > 0) {
+                mPathAnimator.setStartDelay(mStartDelay);
+            }
+
+            final Runnable updatePhysicsAnims = () -> {
+                updateValueForChild(
+                        DynamicAnimation.TRANSLATION_X, mView, mCurrentPointOnPath.x);
+                updateValueForChild(
+                        DynamicAnimation.TRANSLATION_Y, mView, mCurrentPointOnPath.y);
+            };
+
+            mPathAnimator.addUpdateListener(pathAnim -> updatePhysicsAnims.run());
+            mPathAnimator.addListener(new AnimatorListenerAdapter() {
+                @Override
+                public void onAnimationStart(Animator animation) {
+                    animateValueForChild(
+                            DynamicAnimation.TRANSLATION_X,
+                            mView,
+                            mCurrentPointOnPath.x,
+                            mDefaultStartVelocity,
+                            0 /* startDelay */,
+                            mStiffness >= 0 ? mStiffness : defaultSpringForceX.getStiffness(),
+                            mDampingRatio >= 0
+                                    ? mDampingRatio
+                                    : defaultSpringForceX.getDampingRatio());
+
+                    animateValueForChild(
+                            DynamicAnimation.TRANSLATION_Y,
+                            mView,
+                            mCurrentPointOnPath.y,
+                            mDefaultStartVelocity,
+                            0 /* startDelay */,
+                            mStiffness >= 0 ? mStiffness : defaultSpringForceY.getStiffness(),
+                            mDampingRatio >= 0
+                                    ? mDampingRatio
+                                    : defaultSpringForceY.getDampingRatio());
+                }
+
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    updatePhysicsAnims.run();
+                }
+            });
+
+            // If there's a target animator saved for the view, make sure it's not running.
+            final ObjectAnimator targetAnimator = getTargetAnimatorFromView(mView);
+            if (targetAnimator != null) {
+                targetAnimator.cancel();
+            }
+
+            mView.setTag(R.id.target_animator_tag, mPathAnimator);
+            mPathAnimator.start();
+        }
+
+        private void clearAnimator() {
+            mInitialPropertyValues.clear();
+            mAnimatedProperties.clear();
+            mPositionStartVelocities.clear();
+            mDefaultStartVelocity = -Float.MAX_VALUE;
+            mStartDelay = 0;
+            mStiffness = -1;
+            mDampingRatio = -1;
+            mEndActionsForProperty.clear();
+            mPathAnimator = null;
+            mPositionEndActions = null;
+        }
+
+        /**
+         * Sets the controller that last retrieved this animator instance, so that we can prevent
+         * {@link #start} from actually starting animations if called by a non-active controller.
+         */
+        private void setAssociatedController(PhysicsAnimationController controller) {
+            mAssociatedController = controller;
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
new file mode 100644
index 0000000..d7f2e4b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
@@ -0,0 +1,1127 @@
+/*
+ * Copyright (C) 2019 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.wm.shell.bubbles.animation;
+
+import android.content.ContentResolver;
+import android.content.res.Resources;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FlingAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.bubbles.BadgedImageView;
+import com.android.wm.shell.bubbles.BubblePositioner;
+import com.android.wm.shell.bubbles.BubbleStackView;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
+
+import com.google.android.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.function.IntSupplier;
+
+/**
+ * Animation controller for bubbles when they're in their stacked state. Stacked bubbles sit atop
+ * each other with a slight offset to the left or right (depending on which side of the screen they
+ * are on). Bubbles 'follow' each other when dragged, and can be flung to the left or right sides of
+ * the screen.
+ */
+public class StackAnimationController extends
+        PhysicsAnimationLayout.PhysicsAnimationController {
+
+    private static final String TAG = "Bubbs.StackCtrl";
+
+    /** Values to use for animating bubbles in. */
+    private static final float ANIMATE_IN_STIFFNESS = 1000f;
+    private static final int ANIMATE_IN_START_DELAY = 25;
+
+    /** Values to use for animating updated bubble to top of stack. */
+    private static final float BUBBLE_SWAP_SCALE = 0.8f;
+    private static final long BUBBLE_SWAP_DURATION = 300L;
+
+    /**
+     * Values to use for the default {@link SpringForce} provided to the physics animation layout.
+     */
+    public static final int SPRING_TO_TOUCH_STIFFNESS = 12000;
+    public static final float IME_ANIMATION_STIFFNESS = SpringForce.STIFFNESS_LOW;
+    private static final int CHAIN_STIFFNESS = 600;
+    public static final float DEFAULT_BOUNCINESS = 0.9f;
+
+    private final PhysicsAnimator.SpringConfig mAnimateOutSpringConfig =
+            new PhysicsAnimator.SpringConfig(
+                    ANIMATE_IN_STIFFNESS, SpringForce.DAMPING_RATIO_NO_BOUNCY);
+
+    /**
+     * Friction applied to fling animations. Since the stack must land on one of the sides of the
+     * screen, we want less friction horizontally so that the stack has a better chance of making it
+     * to the side without needing a spring.
+     */
+    private static final float FLING_FRICTION = 2.2f;
+
+    /**
+     * Values to use for the stack spring animation used to spring the stack to its final position
+     * after a fling.
+     */
+    private static final int SPRING_AFTER_FLING_STIFFNESS = 750;
+    private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f;
+
+    /** Sentinel value for unset position value. */
+    private static final float UNSET = -Float.MIN_VALUE;
+
+    /**
+     * Minimum fling velocity required to trigger moving the stack from one side of the screen to
+     * the other.
+     */
+    private static final float ESCAPE_VELOCITY = 750f;
+
+    /** Velocity required to dismiss the stack without dragging it into the dismiss target. */
+    private static final float FLING_TO_DISMISS_MIN_VELOCITY = 4000f;
+
+    /**
+     * The canonical position of the stack. This is typically the position of the first bubble, but
+     * we need to keep track of it separately from the first bubble's translation in case there are
+     * no bubbles, or the first bubble was just added and being animated to its new position.
+     */
+    private PointF mStackPosition = new PointF(-1, -1);
+
+    /**
+     * MagnetizedObject instance for the stack, which is used by the touch handler for the magnetic
+     * dismiss target.
+     */
+    private MagnetizedObject<StackAnimationController> mMagnetizedStack;
+
+    /**
+     * The area that Bubbles will occupy after all animations end. This is used to move other
+     * floating content out of the way proactively.
+     */
+    private Rect mAnimatingToBounds = new Rect();
+
+    /** Initial starting location for the stack. */
+    @Nullable private BubbleStackView.RelativeStackPosition mStackStartPosition;
+
+    /** Whether or not the stack's start position has been set. */
+    private boolean mStackMovedToStartPosition = false;
+
+    /**
+     * The stack's most recent position along the edge of the screen. This is saved when the last
+     * bubble is removed, so that the stack can be restored in its previous position.
+     */
+    private PointF mRestingStackPosition;
+
+    /** The height of the most recently visible IME. */
+    private float mImeHeight = 0f;
+
+    /**
+     * The Y position of the stack before the IME became visible, or {@link Float#MIN_VALUE} if the
+     * IME is not visible or the user moved the stack since the IME became visible.
+     */
+    private float mPreImeY = UNSET;
+
+    /**
+     * Animations on the stack position itself, which would have been started in
+     * {@link #flingThenSpringFirstBubbleWithStackFollowing}. These animations dispatch to
+     * {@link #moveFirstBubbleWithStackFollowing} to move the entire stack (with 'following' effect)
+     * to a legal position on the side of the screen.
+     */
+    private HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mStackPositionAnimations =
+            new HashMap<>();
+
+    /**
+     * Whether the current motion of the stack is due to a fling animation (vs. being dragged
+     * manually).
+     */
+    private boolean mIsMovingFromFlinging = false;
+
+    /**
+     * Whether the first bubble is springing towards the touch point, rather than using the default
+     * behavior of moving directly to the touch point with the rest of the stack following it.
+     *
+     * This happens when the user's finger exits the dismiss area while the stack is magnetized to
+     * the center. Since the touch point differs from the stack location, we need to animate the
+     * stack back to the touch point to avoid a jarring instant location change from the center of
+     * the target to the touch point just outside the target bounds.
+     *
+     * This is reset once the spring animations end, since that means the first bubble has
+     * successfully 'caught up' to the touch.
+     */
+    private boolean mFirstBubbleSpringingToTouch = false;
+
+    /**
+     * Whether to spring the stack to the next touch event coordinates. This is used to animate the
+     * stack (including the first bubble) out of the magnetic dismiss target to the touch location.
+     * Once it 'catches up' and the animation ends, we'll revert to moving the first bubble directly
+     * and only animating the following bubbles.
+     */
+    private boolean mSpringToTouchOnNextMotionEvent = false;
+
+    /** Horizontal offset of bubbles in the stack. */
+    private float mStackOffset;
+    /** Offset between stack y and animation y for bubble swap. */
+    private float mSwapAnimationOffset;
+    /** Max number of bubbles to show in the expanded bubble row. */
+    private int mMaxBubbles;
+    /** Default bubble elevation. */
+    private int mElevation;
+    /** Diameter of the bubble icon. */
+    private int mBubbleBitmapSize;
+    /** Width of the bubble (icon and padding). */
+    private int mBubbleSize;
+    /**
+     * The amount of space to add between the bubbles and certain UI elements, such as the top of
+     * the screen or the IME. This does not apply to the left/right sides of the screen since the
+     * stack goes offscreen intentionally.
+     */
+    private int mBubblePaddingTop;
+    /** How far offscreen the stack rests. */
+    private int mBubbleOffscreen;
+    /** Contains display size, orientation, and inset information. */
+    private BubblePositioner mPositioner;
+
+    /** FloatingContentCoordinator instance for resolving floating content conflicts. */
+    private FloatingContentCoordinator mFloatingContentCoordinator;
+
+    /**
+     * FloatingContent instance that returns the stack's location on the screen, and moves it when
+     * requested.
+     */
+    private final FloatingContentCoordinator.FloatingContent mStackFloatingContent =
+            new FloatingContentCoordinator.FloatingContent() {
+
+        private final Rect mFloatingBoundsOnScreen = new Rect();
+
+        @Override
+        public void moveToBounds(@NonNull Rect bounds) {
+            springStack(bounds.left, bounds.top, SpringForce.STIFFNESS_LOW);
+        }
+
+        @NonNull
+        @Override
+        public Rect getAllowedFloatingBoundsRegion() {
+            final Rect floatingBounds = getFloatingBoundsOnScreen();
+            final Rect allowableStackArea = new Rect();
+            getAllowableStackPositionRegion().roundOut(allowableStackArea);
+            allowableStackArea.right += floatingBounds.width();
+            allowableStackArea.bottom += floatingBounds.height();
+            return allowableStackArea;
+        }
+
+        @NonNull
+        @Override
+        public Rect getFloatingBoundsOnScreen() {
+            if (!mAnimatingToBounds.isEmpty()) {
+                return mAnimatingToBounds;
+            }
+
+            if (mLayout.getChildCount() > 0) {
+                // Calculate the bounds using stack position + bubble size so that we don't need to
+                // wait for the bubble views to lay out.
+                mFloatingBoundsOnScreen.set(
+                        (int) mStackPosition.x,
+                        (int) mStackPosition.y,
+                        (int) mStackPosition.x + mBubbleSize,
+                        (int) mStackPosition.y + mBubbleSize + mBubblePaddingTop);
+            } else {
+                mFloatingBoundsOnScreen.setEmpty();
+            }
+
+            return mFloatingBoundsOnScreen;
+        }
+    };
+
+    /** Returns the number of 'real' bubbles (excluding the overflow bubble). */
+    private IntSupplier mBubbleCountSupplier;
+
+    /**
+     * Callback to run whenever any bubble is animated out. The BubbleStackView will check if the
+     * end of this animation means we have no bubbles left, and notify the BubbleController.
+     */
+    private Runnable mOnBubbleAnimatedOutAction;
+
+    public StackAnimationController(
+            FloatingContentCoordinator floatingContentCoordinator,
+            IntSupplier bubbleCountSupplier,
+            Runnable onBubbleAnimatedOutAction,
+            BubblePositioner positioner) {
+        mFloatingContentCoordinator = floatingContentCoordinator;
+        mBubbleCountSupplier = bubbleCountSupplier;
+        mOnBubbleAnimatedOutAction = onBubbleAnimatedOutAction;
+        mPositioner = positioner;
+    }
+
+    /**
+     * Instantly move the first bubble to the given point, and animate the rest of the stack behind
+     * it with the 'following' effect.
+     */
+    public void moveFirstBubbleWithStackFollowing(float x, float y) {
+        // If we're moving the bubble around, we're not animating to any bounds.
+        mAnimatingToBounds.setEmpty();
+
+        // If we manually move the bubbles with the IME open, clear the return point since we don't
+        // want the stack to snap away from the new position.
+        mPreImeY = UNSET;
+
+        moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X, x);
+        moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y, y);
+
+        // This method is called when the stack is being dragged manually, so we're clearly no
+        // longer flinging.
+        mIsMovingFromFlinging = false;
+    }
+
+    /**
+     * The position of the stack - typically the position of the first bubble; if no bubbles have
+     * been added yet, it will be where the first bubble will go when added.
+     */
+    public PointF getStackPosition() {
+        return mStackPosition;
+    }
+
+    /** Whether the stack is on the left side of the screen. */
+    public boolean isStackOnLeftSide() {
+        if (mLayout == null || !isStackPositionSet()) {
+            return true; // Default to left, which is where it starts by default.
+        }
+
+        float stackCenter = mStackPosition.x + mBubbleBitmapSize / 2;
+        float screenCenter = mLayout.getWidth() / 2;
+        return stackCenter < screenCenter;
+    }
+
+    /**
+     * Fling stack to given corner, within allowable screen bounds.
+     * Note that we need new SpringForce instances per animation despite identical configs because
+     * SpringAnimation uses SpringForce's internal (changing) velocity while the animation runs.
+     */
+    public void springStack(
+            float destinationX, float destinationY, float stiffness) {
+        notifyFloatingCoordinatorStackAnimatingTo(destinationX, destinationY);
+
+        springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X,
+                new SpringForce()
+                        .setStiffness(stiffness)
+                        .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
+                0 /* startXVelocity */,
+                destinationX);
+
+        springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y,
+                new SpringForce()
+                        .setStiffness(stiffness)
+                        .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
+                0 /* startYVelocity */,
+                destinationY);
+    }
+
+    /**
+     * Springs the stack to the specified x/y coordinates, with the stiffness used for springs after
+     * flings.
+     */
+    public void springStackAfterFling(float destinationX, float destinationY) {
+        springStack(destinationX, destinationY, SPRING_AFTER_FLING_STIFFNESS);
+    }
+
+    /**
+     * Flings the stack starting with the given velocities, springing it to the nearest edge
+     * afterward.
+     *
+     * @return The X value that the stack will end up at after the fling/spring.
+     */
+    public float flingStackThenSpringToEdge(float x, float velX, float velY) {
+        final boolean stackOnLeftSide = x - mBubbleBitmapSize / 2 < mLayout.getWidth() / 2;
+
+        final boolean stackShouldFlingLeft = stackOnLeftSide
+                ? velX < ESCAPE_VELOCITY
+                : velX < -ESCAPE_VELOCITY;
+
+        final RectF stackBounds = getAllowableStackPositionRegion();
+
+        // Target X translation (either the left or right side of the screen).
+        final float destinationRelativeX = stackShouldFlingLeft
+                ? stackBounds.left : stackBounds.right;
+
+        // If all bubbles were removed during a drag event, just return the X we would have animated
+        // to if there were still bubbles.
+        if (mLayout == null || mLayout.getChildCount() == 0) {
+            return destinationRelativeX;
+        }
+
+        final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
+        final float stiffness = Settings.Secure.getFloat(contentResolver, "bubble_stiffness",
+                SPRING_AFTER_FLING_STIFFNESS /* default */);
+        final float dampingRatio = Settings.Secure.getFloat(contentResolver, "bubble_damping",
+                SPRING_AFTER_FLING_DAMPING_RATIO);
+        final float friction = Settings.Secure.getFloat(contentResolver, "bubble_friction",
+                FLING_FRICTION);
+
+        // Minimum velocity required for the stack to make it to the targeted side of the screen,
+        // taking friction into account (4.2f is the number that friction scalars are multiplied by
+        // in DynamicAnimation.DragForce). This is an estimate - it could possibly be slightly off,
+        // but the SpringAnimation at the end will ensure that it reaches the destination X
+        // regardless.
+        final float minimumVelocityToReachEdge =
+                (destinationRelativeX - x) * (friction * 4.2f);
+
+        final float estimatedY = PhysicsAnimator.estimateFlingEndValue(
+                mStackPosition.y, velY,
+                new PhysicsAnimator.FlingConfig(
+                        friction, stackBounds.top, stackBounds.bottom));
+
+        notifyFloatingCoordinatorStackAnimatingTo(destinationRelativeX, estimatedY);
+
+        // Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity so
+        // that it'll make it all the way to the side of the screen.
+        final float startXVelocity = stackShouldFlingLeft
+                ? Math.min(minimumVelocityToReachEdge, velX)
+                : Math.max(minimumVelocityToReachEdge, velX);
+
+
+
+        flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_X,
+                startXVelocity,
+                friction,
+                new SpringForce()
+                        .setStiffness(stiffness)
+                        .setDampingRatio(dampingRatio),
+                destinationRelativeX);
+
+        flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_Y,
+                velY,
+                friction,
+                new SpringForce()
+                        .setStiffness(stiffness)
+                        .setDampingRatio(dampingRatio),
+                /* destination */ null);
+
+        // If we're flinging now, there's no more touch event to catch up to.
+        mFirstBubbleSpringingToTouch = false;
+        mIsMovingFromFlinging = true;
+        return destinationRelativeX;
+    }
+
+    /**
+     * Where the stack would be if it were snapped to the nearest horizontal edge (left or right).
+     */
+    public PointF getStackPositionAlongNearestHorizontalEdge() {
+        final PointF stackPos = getStackPosition();
+        final boolean onLeft = mLayout.isFirstChildXLeftOfCenter(stackPos.x);
+        final RectF bounds = getAllowableStackPositionRegion();
+
+        stackPos.x = onLeft ? bounds.left : bounds.right;
+        return stackPos;
+    }
+
+    /** Description of current animation controller state. */
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println("StackAnimationController state:");
+        pw.print("  isActive:             "); pw.println(isActiveController());
+        pw.print("  restingStackPos:      ");
+        pw.println(mRestingStackPosition != null ? mRestingStackPosition.toString() : "null");
+        pw.print("  currentStackPos:      "); pw.println(mStackPosition.toString());
+        pw.print("  isMovingFromFlinging: "); pw.println(mIsMovingFromFlinging);
+        pw.print("  withinDismiss:        "); pw.println(isStackStuckToTarget());
+        pw.print("  firstBubbleSpringing: "); pw.println(mFirstBubbleSpringingToTouch);
+    }
+
+    /**
+     * Flings the first bubble along the given property's axis, using the provided configuration
+     * values. When the animation ends - either by hitting the min/max, or by friction sufficiently
+     * reducing momentum - a SpringAnimation takes over to snap the bubble to the given final
+     * position.
+     */
+    protected void flingThenSpringFirstBubbleWithStackFollowing(
+            DynamicAnimation.ViewProperty property,
+            float vel,
+            float friction,
+            SpringForce spring,
+            Float finalPosition) {
+        if (!isActiveController()) {
+            return;
+        }
+
+        Log.d(TAG, String.format("Flinging %s.",
+                PhysicsAnimationLayout.getReadablePropertyName(property)));
+
+        StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
+        final float currentValue = firstBubbleProperty.getValue(this);
+        final RectF bounds = getAllowableStackPositionRegion();
+        final float min =
+                property.equals(DynamicAnimation.TRANSLATION_X)
+                        ? bounds.left
+                        : bounds.top;
+        final float max =
+                property.equals(DynamicAnimation.TRANSLATION_X)
+                        ? bounds.right
+                        : bounds.bottom;
+
+        FlingAnimation flingAnimation = new FlingAnimation(this, firstBubbleProperty);
+        flingAnimation.setFriction(friction)
+                .setStartVelocity(vel)
+
+                // If the bubble's property value starts beyond the desired min/max, use that value
+                // instead so that the animation won't immediately end. If, for example, the user
+                // drags the bubbles into the navigation bar, but then flings them upward, we want
+                // the fling to occur despite temporarily having a value outside of the min/max. If
+                // the bubbles are out of bounds and flung even farther out of bounds, the fling
+                // animation will halt immediately and the SpringAnimation will take over, springing
+                // it in reverse to the (legal) final position.
+                .setMinValue(Math.min(currentValue, min))
+                .setMaxValue(Math.max(currentValue, max))
+
+                .addEndListener((animation, canceled, endValue, endVelocity) -> {
+                    if (!canceled) {
+                        mRestingStackPosition.set(mStackPosition);
+
+                        springFirstBubbleWithStackFollowing(property, spring, endVelocity,
+                                finalPosition != null
+                                        ? finalPosition
+                                        : Math.max(min, Math.min(max, endValue)));
+                    }
+                });
+
+        cancelStackPositionAnimation(property);
+        mStackPositionAnimations.put(property, flingAnimation);
+        flingAnimation.start();
+    }
+
+    /**
+     * Cancel any stack position animations that were started by calling
+     * @link #flingThenSpringFirstBubbleWithStackFollowing}, and remove any corresponding end
+     * listeners.
+     */
+    public void cancelStackPositionAnimations() {
+        cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
+        cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
+
+        removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
+        removeEndActionForProperty(DynamicAnimation.TRANSLATION_Y);
+    }
+
+    /** Save the current IME height so that we know where the stack bounds should be. */
+    public void setImeHeight(int imeHeight) {
+        mImeHeight = imeHeight;
+    }
+
+    /**
+     * Animates the stack either away from the newly visible IME, or back to its original position
+     * due to the IME going away.
+     *
+     * @return The destination Y value of the stack due to the IME movement (or the current position
+     * of the stack if it's not moving).
+     */
+    public float animateForImeVisibility(boolean imeVisible) {
+        final float maxBubbleY = getAllowableStackPositionRegion().bottom;
+        float destinationY = UNSET;
+
+        if (imeVisible) {
+            // Stack is lower than it should be and overlaps the now-visible IME.
+            if (mStackPosition.y > maxBubbleY && mPreImeY == UNSET) {
+                mPreImeY = mStackPosition.y;
+                destinationY = maxBubbleY;
+            }
+        } else {
+            if (mPreImeY != UNSET) {
+                destinationY = mPreImeY;
+                mPreImeY = UNSET;
+            }
+        }
+
+        if (destinationY != UNSET) {
+            springFirstBubbleWithStackFollowing(
+                    DynamicAnimation.TRANSLATION_Y,
+                    getSpringForce(DynamicAnimation.TRANSLATION_Y, /* view */ null)
+                            .setStiffness(IME_ANIMATION_STIFFNESS),
+                    /* startVel */ 0f,
+                    destinationY);
+
+            notifyFloatingCoordinatorStackAnimatingTo(mStackPosition.x, destinationY);
+        }
+
+        return destinationY != UNSET ? destinationY : mStackPosition.y;
+    }
+
+    /**
+     * Notifies the floating coordinator that we're moving, and sets {@link #mAnimatingToBounds} so
+     * we return these bounds from
+     * {@link FloatingContentCoordinator.FloatingContent#getFloatingBoundsOnScreen()}.
+     */
+    private void notifyFloatingCoordinatorStackAnimatingTo(float x, float y) {
+        final Rect floatingBounds = mStackFloatingContent.getFloatingBoundsOnScreen();
+        floatingBounds.offsetTo((int) x, (int) y);
+        mAnimatingToBounds = floatingBounds;
+        mFloatingContentCoordinator.onContentMoved(mStackFloatingContent);
+    }
+
+    /**
+     * Returns the region that the stack position must stay within. This goes slightly off the left
+     * and right sides of the screen, below the status bar/cutout and above the navigation bar.
+     * While the stack position is not allowed to rest outside of these bounds, it can temporarily
+     * be animated or dragged beyond them.
+     */
+    public RectF getAllowableStackPositionRegion() {
+        final RectF allowableRegion = new RectF(mPositioner.getAvailableRect());
+        allowableRegion.left -= mBubbleOffscreen;
+        allowableRegion.top += mBubblePaddingTop;
+        allowableRegion.right += mBubbleOffscreen - mBubbleSize;
+        allowableRegion.bottom -= mBubblePaddingTop + mBubbleSize
+                + (mImeHeight != UNSET ? mImeHeight + mBubblePaddingTop : 0f);
+        return allowableRegion;
+    }
+
+    /** Moves the stack in response to a touch event. */
+    public void moveStackFromTouch(float x, float y) {
+        // Begin the spring-to-touch catch up animation if needed.
+        if (mSpringToTouchOnNextMotionEvent) {
+            springStack(x, y, SPRING_TO_TOUCH_STIFFNESS);
+            mSpringToTouchOnNextMotionEvent = false;
+            mFirstBubbleSpringingToTouch = true;
+        } else if (mFirstBubbleSpringingToTouch) {
+            final SpringAnimation springToTouchX =
+                    (SpringAnimation) mStackPositionAnimations.get(
+                            DynamicAnimation.TRANSLATION_X);
+            final SpringAnimation springToTouchY =
+                    (SpringAnimation) mStackPositionAnimations.get(
+                            DynamicAnimation.TRANSLATION_Y);
+
+            // If either animation is still running, we haven't caught up. Update the animations.
+            if (springToTouchX.isRunning() || springToTouchY.isRunning()) {
+                springToTouchX.animateToFinalPosition(x);
+                springToTouchY.animateToFinalPosition(y);
+            } else {
+                // If the animations have finished, the stack is now at the touch point. We can
+                // resume moving the bubble directly.
+                mFirstBubbleSpringingToTouch = false;
+            }
+        }
+
+        if (!mFirstBubbleSpringingToTouch && !isStackStuckToTarget()) {
+            moveFirstBubbleWithStackFollowing(x, y);
+        }
+    }
+
+    /** Notify the controller that the stack has been unstuck from the dismiss target. */
+    public void onUnstuckFromTarget() {
+        mSpringToTouchOnNextMotionEvent = true;
+    }
+
+    /**
+     * 'Implode' the stack by shrinking the bubbles, fading them out, and translating them down.
+     */
+    public void animateStackDismissal(float translationYBy, Runnable after) {
+        animationsForChildrenFromIndex(0, (index, animation) ->
+                animation
+                        .scaleX(0f)
+                        .scaleY(0f)
+                        .alpha(0f)
+                        .translationY(
+                                mLayout.getChildAt(index).getTranslationY() + translationYBy)
+                        .withStiffness(SpringForce.STIFFNESS_HIGH))
+                .startAll(after);
+    }
+
+    /**
+     * Springs the first bubble to the given final position, with the rest of the stack 'following'.
+     */
+    protected void springFirstBubbleWithStackFollowing(
+            DynamicAnimation.ViewProperty property, SpringForce spring,
+            float vel, float finalPosition, @Nullable Runnable... after) {
+
+        if (mLayout.getChildCount() == 0 || !isActiveController()) {
+            return;
+        }
+
+        Log.d(TAG, String.format("Springing %s to final position %f.",
+                PhysicsAnimationLayout.getReadablePropertyName(property),
+                finalPosition));
+
+        // Whether we're springing towards the touch location, rather than to a position on the
+        // sides of the screen.
+        final boolean isSpringingTowardsTouch = mSpringToTouchOnNextMotionEvent;
+
+        StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
+        SpringAnimation springAnimation =
+                new SpringAnimation(this, firstBubbleProperty)
+                        .setSpring(spring)
+                        .addEndListener((dynamicAnimation, b, v, v1) -> {
+                            if (!isSpringingTowardsTouch) {
+                                // If we're springing towards the touch position, don't save the
+                                // resting position - the touch location is not a valid resting
+                                // position. We'll set this when the stack springs to the left or
+                                // right side of the screen after the touch gesture ends.
+                                mRestingStackPosition.set(mStackPosition);
+                            }
+
+                            if (after != null) {
+                                for (Runnable callback : after) {
+                                    callback.run();
+                                }
+                            }
+                        })
+                        .setStartVelocity(vel);
+
+        cancelStackPositionAnimation(property);
+        mStackPositionAnimations.put(property, springAnimation);
+        springAnimation.animateToFinalPosition(finalPosition);
+    }
+
+    @Override
+    Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+        return Sets.newHashSet(
+                DynamicAnimation.TRANSLATION_X, // For positioning.
+                DynamicAnimation.TRANSLATION_Y,
+                DynamicAnimation.ALPHA,         // For fading in new bubbles.
+                DynamicAnimation.SCALE_X,       // For 'popping in' new bubbles.
+                DynamicAnimation.SCALE_Y);
+    }
+
+    @Override
+    int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+        if (property.equals(DynamicAnimation.TRANSLATION_X)
+                || property.equals(DynamicAnimation.TRANSLATION_Y)) {
+            return index + 1;
+        } else {
+            return NONE;
+        }
+    }
+
+
+    @Override
+    float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+        if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+            // If we're in the dismiss target, have the bubbles pile on top of each other with no
+            // offset.
+            if (isStackStuckToTarget()) {
+                return 0f;
+            } else {
+                return mStackOffset;
+            }
+        } else {
+            return 0f;
+        }
+    }
+
+    @Override
+    SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+        final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
+        final float dampingRatio = Settings.Secure.getFloat(contentResolver, "bubble_damping",
+                DEFAULT_BOUNCINESS);
+
+        return new SpringForce()
+                .setDampingRatio(dampingRatio)
+                .setStiffness(CHAIN_STIFFNESS);
+    }
+
+    @Override
+    void onChildAdded(View child, int index) {
+        // Don't animate additions within the dismiss target.
+        if (isStackStuckToTarget()) {
+            return;
+        }
+
+        if (getBubbleCount() == 1) {
+            // If this is the first child added, position the stack in its starting position.
+            moveStackToStartPosition();
+        } else if (isStackPositionSet() && mLayout.indexOfChild(child) == 0) {
+            // Otherwise, animate the bubble in if it's the newest bubble. If we're adding a bubble
+            // to the back of the stack, it'll be largely invisible so don't bother animating it in.
+            animateInBubble(child, index);
+        }
+    }
+
+    @Override
+    void onChildRemoved(View child, int index, Runnable finishRemoval) {
+        PhysicsAnimator.getInstance(child)
+                .spring(DynamicAnimation.ALPHA, 0f)
+                .spring(DynamicAnimation.SCALE_X, 0f, mAnimateOutSpringConfig)
+                .spring(DynamicAnimation.SCALE_Y, 0f, mAnimateOutSpringConfig)
+                .withEndActions(finishRemoval, mOnBubbleAnimatedOutAction)
+                .start();
+
+        // If there are other bubbles, pull them into the correct position.
+        if (getBubbleCount() > 0) {
+            animationForChildAtIndex(0).translationX(mStackPosition.x).start();
+        } else {
+            // When all children are removed ensure stack position is sane
+            setStackPosition(mRestingStackPosition == null
+                    ? getStartPosition()
+                    : mRestingStackPosition);
+
+            // Remove the stack from the coordinator since we don't have any bubbles and aren't
+            // visible.
+            mFloatingContentCoordinator.onContentRemoved(mStackFloatingContent);
+        }
+    }
+
+    public void animateReorder(List<View> bubbleViews, Runnable after) {
+        for (int newIndex = 0; newIndex < bubbleViews.size(); newIndex++) {
+            View view = bubbleViews.get(newIndex);
+            final int oldIndex = mLayout.indexOfChild(view);
+            animateSwap(view, oldIndex, newIndex, after);
+        }
+    }
+
+    private void animateSwap(View view, int oldIndex, int newIndex, Runnable finishReorder) {
+        final float newY = getStackPosition().y + newIndex * mSwapAnimationOffset;
+        final float swapY = newIndex == 0
+                ? newY - mSwapAnimationOffset  // Above top of stack
+                : newY + mSwapAnimationOffset;  // Below where bubble will be
+        final ViewPropertyAnimator animator = view.animate()
+                .scaleX(BUBBLE_SWAP_SCALE)
+                .scaleY(BUBBLE_SWAP_SCALE)
+                .translationY(swapY)
+                .setDuration(BUBBLE_SWAP_DURATION)
+                .withEndAction(() -> {
+                    finishSwapAnimation(view, oldIndex, newIndex, finishReorder);
+                });
+        view.setTag(R.id.reorder_animator_tag, animator);
+    }
+
+    private void finishSwapAnimation(View view, int oldIndex, int newIndex,
+            Runnable finishReorder) {
+
+        // At this point, swapping bubbles have the least overlap.
+        // Update z-index and badge visibility here for least jarring transition.
+        view.setZ((mMaxBubbles * mElevation) - newIndex);
+        BadgedImageView bv = (BadgedImageView) view;
+        if (oldIndex == 0 && newIndex > 0) {
+            bv.hideDotAndBadge(!isStackOnLeftSide());
+        } else if (oldIndex > 0 && newIndex == 0) {
+            bv.showDotAndBadge(!isStackOnLeftSide());
+        }
+
+        // Animate bubble back into stack, at new index and original size.
+        final float newY = getStackPosition().y + newIndex * mStackOffset;
+        final ViewPropertyAnimator animator = view.animate()
+                .scaleX(1f)
+                .scaleY(1f)
+                .translationY(newY)
+                .setDuration(BUBBLE_SWAP_DURATION)
+                .withEndAction(() -> {
+                    view.setTag(R.id.reorder_animator_tag, null);
+                    finishReorder.run();
+                });
+        view.setTag(R.id.reorder_animator_tag, animator);
+    }
+
+    @Override
+    void onChildReordered(View child, int oldIndex, int newIndex) {
+        if (isStackPositionSet()) {
+            setStackPosition(mStackPosition);
+        }
+    }
+
+    @Override
+    void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
+        Resources res = layout.getResources();
+        mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
+        mSwapAnimationOffset = res.getDimensionPixelSize(R.dimen.bubble_swap_animation_offset);
+        mMaxBubbles = res.getInteger(R.integer.bubbles_max_rendered);
+        mElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+        mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mBubbleBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_bitmap_size);
+        mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        mBubbleOffscreen = res.getDimensionPixelSize(R.dimen.bubble_stack_offscreen);
+    }
+
+    /**
+     * Update resources.
+     */
+    public void updateResources() {
+        if (mLayout != null) {
+            Resources res = mLayout.getContext().getResources();
+            mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
+        }
+    }
+
+    private boolean isStackStuckToTarget() {
+        return mMagnetizedStack != null && mMagnetizedStack.getObjectStuckToTarget();
+    }
+
+    /** Moves the stack, without any animation, to the starting position. */
+    private void moveStackToStartPosition() {
+        // Post to ensure that the layout's width and height have been calculated.
+        mLayout.setVisibility(View.INVISIBLE);
+        mLayout.post(() -> {
+            setStackPosition(mRestingStackPosition == null
+                    ? getStartPosition()
+                    : mRestingStackPosition);
+            mStackMovedToStartPosition = true;
+            mLayout.setVisibility(View.VISIBLE);
+
+            // Animate in the top bubble now that we're visible.
+            if (mLayout.getChildCount() > 0) {
+                // Add the stack to the floating content coordinator now that we have a bubble and
+                // are visible.
+                mFloatingContentCoordinator.onContentAdded(mStackFloatingContent);
+
+                animateInBubble(mLayout.getChildAt(0), 0 /* index */);
+            }
+        });
+    }
+
+    /**
+     * Moves the first bubble instantly to the given X or Y translation, and instructs subsequent
+     * bubbles to animate 'following' to the new location.
+     */
+    private void moveFirstBubbleWithStackFollowing(
+            DynamicAnimation.ViewProperty property, float value) {
+
+        // Update the canonical stack position.
+        if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+            mStackPosition.x = value;
+        } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+            mStackPosition.y = value;
+        }
+
+        if (mLayout.getChildCount() > 0) {
+            property.setValue(mLayout.getChildAt(0), value);
+            if (mLayout.getChildCount() > 1) {
+                animationForChildAtIndex(1)
+                        .property(property, value + getOffsetForChainedPropertyAnimation(property))
+                        .start();
+            }
+        }
+    }
+
+    /** Moves the stack to a position instantly, with no animation. */
+    public void setStackPosition(PointF pos) {
+        Log.d(TAG, String.format("Setting position to (%f, %f).", pos.x, pos.y));
+        mStackPosition.set(pos.x, pos.y);
+
+        if (mRestingStackPosition == null) {
+            mRestingStackPosition = new PointF();
+        }
+
+        mRestingStackPosition.set(mStackPosition);
+
+        // If we're not the active controller, we don't want to physically move the bubble views.
+        if (isActiveController()) {
+            // Cancel animations that could be moving the views.
+            mLayout.cancelAllAnimationsOfProperties(
+                    DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+            cancelStackPositionAnimations();
+
+            // Since we're not using the chained animations, apply the offsets manually.
+            final float xOffset = getOffsetForChainedPropertyAnimation(
+                    DynamicAnimation.TRANSLATION_X);
+            final float yOffset = getOffsetForChainedPropertyAnimation(
+                    DynamicAnimation.TRANSLATION_Y);
+            for (int i = 0; i < mLayout.getChildCount(); i++) {
+                mLayout.getChildAt(i).setTranslationX(pos.x + (i * xOffset));
+                mLayout.getChildAt(i).setTranslationY(pos.y + (i * yOffset));
+            }
+        }
+    }
+
+    public void setStackPosition(BubbleStackView.RelativeStackPosition position) {
+        setStackPosition(position.getAbsolutePositionInRegion(getAllowableStackPositionRegion()));
+    }
+
+    public BubbleStackView.RelativeStackPosition getRelativeStackPosition() {
+        return new BubbleStackView.RelativeStackPosition(
+                mStackPosition, getAllowableStackPositionRegion());
+    }
+
+    /**
+     * Sets the starting position for the stack, where it will be located when the first bubble is
+     * added.
+     */
+    public void setStackStartPosition(BubbleStackView.RelativeStackPosition position) {
+        mStackStartPosition = position;
+    }
+
+    /**
+     * Returns the starting stack position. If {@link #setStackStartPosition} was called, this will
+     * return that position - otherwise, a reasonable default will be returned.
+     */
+    @Nullable public PointF getStartPosition() {
+        if (mLayout == null) {
+            return null;
+        }
+
+        if (mStackStartPosition == null) {
+            // Start on the left if we're in LTR, right otherwise.
+            final boolean startOnLeft =
+                    mLayout.getResources().getConfiguration().getLayoutDirection()
+                            != View.LAYOUT_DIRECTION_RTL;
+
+            final float startingVerticalOffset = mLayout.getResources().getDimensionPixelOffset(
+                    R.dimen.bubble_stack_starting_offset_y);
+
+            mStackStartPosition = new BubbleStackView.RelativeStackPosition(
+                    startOnLeft,
+                    startingVerticalOffset / getAllowableStackPositionRegion().height());
+        }
+
+        return mStackStartPosition.getAbsolutePositionInRegion(getAllowableStackPositionRegion());
+    }
+
+    private boolean isStackPositionSet() {
+        return mStackMovedToStartPosition;
+    }
+
+    /** Animates in the given bubble. */
+    private void animateInBubble(View child, int index) {
+        if (!isActiveController()) {
+            return;
+        }
+
+        final float yOffset =
+                getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_Y);
+
+        // Position the new bubble in the correct position, scaled down completely.
+        child.setTranslationX(mStackPosition.x);
+        child.setTranslationY(mStackPosition.y + yOffset * index);
+        child.setScaleX(0f);
+        child.setScaleY(0f);
+
+        // Push the subsequent views out of the way, if there are subsequent views.
+        if (index + 1 < mLayout.getChildCount()) {
+            animationForChildAtIndex(index + 1)
+                    .translationY(mStackPosition.y + yOffset * (index + 1))
+                    .withStiffness(SpringForce.STIFFNESS_LOW)
+                    .start();
+        }
+
+        // Scale in the new bubble, slightly delayed.
+        animationForChild(child)
+                .scaleX(1f)
+                .scaleY(1f)
+                .withStiffness(ANIMATE_IN_STIFFNESS)
+                .withStartDelay(mLayout.getChildCount() > 1 ? ANIMATE_IN_START_DELAY : 0)
+                .start();
+    }
+
+    /**
+     * Cancels any outstanding first bubble property animations that are running. This does not
+     * affect the SpringAnimations controlling the individual bubbles' 'following' effect - it only
+     * cancels animations started from {@link #springFirstBubbleWithStackFollowing} and
+     * {@link #flingThenSpringFirstBubbleWithStackFollowing}.
+     */
+    private void cancelStackPositionAnimation(DynamicAnimation.ViewProperty property) {
+        if (mStackPositionAnimations.containsKey(property)) {
+            mStackPositionAnimations.get(property).cancel();
+        }
+    }
+
+    /**
+     * Returns the {@link MagnetizedObject} instance for the bubble stack, with the provided
+     * {@link MagnetizedObject.MagneticTarget} added as a target.
+     */
+    public MagnetizedObject<StackAnimationController> getMagnetizedStack(
+            MagnetizedObject.MagneticTarget target) {
+        if (mMagnetizedStack == null) {
+            mMagnetizedStack = new MagnetizedObject<StackAnimationController>(
+                    mLayout.getContext(),
+                    this,
+                    new StackPositionProperty(DynamicAnimation.TRANSLATION_X),
+                    new StackPositionProperty(DynamicAnimation.TRANSLATION_Y)
+            ) {
+                @Override
+                public float getWidth(@NonNull StackAnimationController underlyingObject) {
+                    return mBubbleSize;
+                }
+
+                @Override
+                public float getHeight(@NonNull StackAnimationController underlyingObject) {
+                    return mBubbleSize;
+                }
+
+                @Override
+                public void getLocationOnScreen(@NonNull StackAnimationController underlyingObject,
+                        @NonNull int[] loc) {
+                    loc[0] = (int) mStackPosition.x;
+                    loc[1] = (int) mStackPosition.y;
+                }
+            };
+            mMagnetizedStack.addTarget(target);
+            mMagnetizedStack.setHapticsEnabled(true);
+            mMagnetizedStack.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
+        }
+
+        final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
+        final float minVelocity = Settings.Secure.getFloat(contentResolver,
+                "bubble_dismiss_fling_min_velocity",
+                mMagnetizedStack.getFlingToTargetMinVelocity() /* default */);
+        final float maxVelocity = Settings.Secure.getFloat(contentResolver,
+                "bubble_dismiss_stick_max_velocity",
+                mMagnetizedStack.getStickToTargetMaxXVelocity() /* default */);
+        final float targetWidth = Settings.Secure.getFloat(contentResolver,
+                "bubble_dismiss_target_width_percent",
+                mMagnetizedStack.getFlingToTargetWidthPercent() /* default */);
+
+        mMagnetizedStack.setFlingToTargetMinVelocity(minVelocity);
+        mMagnetizedStack.setStickToTargetMaxXVelocity(maxVelocity);
+        mMagnetizedStack.setFlingToTargetWidthPercent(targetWidth);
+
+        return mMagnetizedStack;
+    }
+
+    /** Returns the number of 'real' bubbles (excluding overflow). */
+    private int getBubbleCount() {
+        return mBubbleCountSupplier.getAsInt();
+    }
+
+    /**
+     * FloatProperty that uses {@link #moveFirstBubbleWithStackFollowing} to set the first bubble's
+     * translation and animate the rest of the stack with it. A DynamicAnimation can animate this
+     * property directly to move the first bubble and cause the stack to 'follow' to the new
+     * location.
+     *
+     * This could also be achieved by simply animating the first bubble view and adding an update
+     * listener to dispatch movement to the rest of the stack. However, this would require
+     * duplication of logic in that update handler - it's simpler to keep all logic contained in the
+     * {@link #moveFirstBubbleWithStackFollowing} method.
+     */
+    private class StackPositionProperty
+            extends FloatPropertyCompat<StackAnimationController> {
+        private final DynamicAnimation.ViewProperty mProperty;
+
+        private StackPositionProperty(DynamicAnimation.ViewProperty property) {
+            super(property.toString());
+            mProperty = property;
+        }
+
+        @Override
+        public float getValue(StackAnimationController controller) {
+            return mLayout.getChildCount() > 0 ? mProperty.getValue(mLayout.getChildAt(0)) : 0;
+        }
+
+        @Override
+        public void setValue(StackAnimationController controller, float value) {
+            moveFirstBubbleWithStackFollowing(mProperty, value);
+        }
+    }
+}
+
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleEntity.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleEntity.kt
new file mode 100644
index 0000000..aeba302
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleEntity.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.annotation.DimenRes
+import android.annotation.UserIdInt
+
+data class BubbleEntity(
+    @UserIdInt val userId: Int,
+    val packageName: String,
+    val shortcutId: String,
+    val key: String,
+    val desiredHeight: Int,
+    @DimenRes val desiredHeightResId: Int,
+    val title: String? = null
+)
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepository.kt
new file mode 100644
index 0000000..66a75af
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepository.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.content.Context
+import android.util.AtomicFile
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+class BubblePersistentRepository(context: Context) {
+
+    private val bubbleFile: AtomicFile = AtomicFile(File(context.filesDir,
+            "overflow_bubbles.xml"), "overflow-bubbles")
+
+    fun persistsToDisk(bubbles: List<BubbleEntity>): Boolean {
+        if (DEBUG) Log.d(TAG, "persisting ${bubbles.size} bubbles")
+        synchronized(bubbleFile) {
+            val stream: FileOutputStream = try { bubbleFile.startWrite() } catch (e: IOException) {
+                Log.e(TAG, "Failed to save bubble file", e)
+                return false
+            }
+            try {
+                writeXml(stream, bubbles)
+                bubbleFile.finishWrite(stream)
+                if (DEBUG) Log.d(TAG, "persisted ${bubbles.size} bubbles")
+                return true
+            } catch (e: Exception) {
+                Log.e(TAG, "Failed to save bubble file, restoring backup", e)
+                bubbleFile.failWrite(stream)
+            }
+        }
+        return false
+    }
+
+    fun readFromDisk(): List<BubbleEntity> {
+        synchronized(bubbleFile) {
+            if (!bubbleFile.exists()) return emptyList()
+            try { return bubbleFile.openRead().use(::readXml) } catch (e: Throwable) {
+                Log.e(TAG, "Failed to open bubble file", e)
+            }
+            return emptyList()
+        }
+    }
+}
+
+private const val TAG = "BubblePersistentRepository"
+private const val DEBUG = false
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepository.kt
new file mode 100644
index 0000000..7f0b165
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepository.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.content.pm.LauncherApps
+import android.os.UserHandle
+import com.android.internal.annotations.VisibleForTesting
+import com.android.wm.shell.bubbles.ShortcutKey
+
+private const val CAPACITY = 16
+
+/**
+ * BubbleVolatileRepository holds the most updated snapshot of list of bubbles for in-memory
+ * manipulation.
+ */
+class BubbleVolatileRepository(private val launcherApps: LauncherApps) {
+    /**
+     * An ordered set of bubbles based on their natural ordering.
+     */
+    private var entities = mutableSetOf<BubbleEntity>()
+
+    /**
+     * The capacity of the cache.
+     */
+    @VisibleForTesting
+    var capacity = CAPACITY
+
+    /**
+     * Returns a snapshot of all the bubbles.
+     */
+    val bubbles: List<BubbleEntity>
+        @Synchronized
+        get() = entities.toList()
+
+    /**
+     * Add the bubbles to memory and perform a de-duplication. In case a bubble already exists,
+     * it will be moved to the last.
+     */
+    @Synchronized
+    fun addBubbles(bubbles: List<BubbleEntity>) {
+        if (bubbles.isEmpty()) return
+        // Verify the size of given bubbles is within capacity, otherwise trim down to capacity
+        val bubblesInRange = bubbles.takeLast(capacity)
+        // To ensure natural ordering of the bubbles, removes bubbles which already exist
+        val uniqueBubbles = bubblesInRange.filterNot { b: BubbleEntity ->
+            entities.removeIf { e: BubbleEntity -> b.key == e.key } }
+        val overflowCount = entities.size + bubblesInRange.size - capacity
+        if (overflowCount > 0) {
+            // Uncache ShortcutInfo of bubbles that will be removed due to capacity
+            uncache(entities.take(overflowCount))
+            entities = entities.drop(overflowCount).toMutableSet()
+        }
+        entities.addAll(bubblesInRange)
+        cache(uniqueBubbles)
+    }
+
+    @Synchronized
+    fun removeBubbles(bubbles: List<BubbleEntity>) =
+            uncache(bubbles.filter { b: BubbleEntity ->
+                entities.removeIf { e: BubbleEntity -> b.key == e.key } })
+
+    private fun cache(bubbles: List<BubbleEntity>) {
+        bubbles.groupBy { ShortcutKey(it.userId, it.packageName) }.forEach { (key, bubbles) ->
+            launcherApps.cacheShortcuts(key.pkg, bubbles.map { it.shortcutId },
+                    UserHandle.of(key.userId), LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS)
+        }
+    }
+
+    private fun uncache(bubbles: List<BubbleEntity>) {
+        bubbles.groupBy { ShortcutKey(it.userId, it.packageName) }.forEach { (key, bubbles) ->
+            launcherApps.uncacheShortcuts(key.pkg, bubbles.map { it.shortcutId },
+                    UserHandle.of(key.userId), LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS)
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelper.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelper.kt
new file mode 100644
index 0000000..fe72bd3
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelper.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.util.Xml
+import com.android.internal.util.FastXmlSerializer
+import com.android.internal.util.XmlUtils
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlSerializer
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.charset.StandardCharsets
+
+// TODO: handle version changes gracefully
+private const val CURRENT_VERSION = 1
+
+private const val TAG_BUBBLES = "bs"
+private const val ATTR_VERSION = "v"
+private const val TAG_BUBBLE = "bb"
+private const val ATTR_USER_ID = "uid"
+private const val ATTR_PACKAGE = "pkg"
+private const val ATTR_SHORTCUT_ID = "sid"
+private const val ATTR_KEY = "key"
+private const val ATTR_DESIRED_HEIGHT = "h"
+private const val ATTR_DESIRED_HEIGHT_RES_ID = "hid"
+private const val ATTR_TITLE = "t"
+
+/**
+ * Writes the bubbles in xml format into given output stream.
+ */
+@Throws(IOException::class)
+fun writeXml(stream: OutputStream, bubbles: List<BubbleEntity>) {
+    val serializer: XmlSerializer = FastXmlSerializer()
+    serializer.setOutput(stream, StandardCharsets.UTF_8.name())
+    serializer.startDocument(null, true)
+    serializer.startTag(null, TAG_BUBBLES)
+    serializer.attribute(null, ATTR_VERSION, CURRENT_VERSION.toString())
+    bubbles.forEach { b -> writeXmlEntry(serializer, b) }
+    serializer.endTag(null, TAG_BUBBLES)
+    serializer.endDocument()
+}
+
+/**
+ * Creates a xml entry for given bubble in following format:
+ * ```
+ * <bb uid="0" pkg="com.example.messenger" sid="my-shortcut" key="my-key" />
+ * ```
+ */
+private fun writeXmlEntry(serializer: XmlSerializer, bubble: BubbleEntity) {
+    try {
+        serializer.startTag(null, TAG_BUBBLE)
+        serializer.attribute(null, ATTR_USER_ID, bubble.userId.toString())
+        serializer.attribute(null, ATTR_PACKAGE, bubble.packageName)
+        serializer.attribute(null, ATTR_SHORTCUT_ID, bubble.shortcutId)
+        serializer.attribute(null, ATTR_KEY, bubble.key)
+        serializer.attribute(null, ATTR_DESIRED_HEIGHT, bubble.desiredHeight.toString())
+        serializer.attribute(null, ATTR_DESIRED_HEIGHT_RES_ID, bubble.desiredHeightResId.toString())
+        bubble.title?.let { serializer.attribute(null, ATTR_TITLE, it) }
+        serializer.endTag(null, TAG_BUBBLE)
+    } catch (e: IOException) {
+        throw RuntimeException(e)
+    }
+}
+
+/**
+ * Reads the bubbles from xml file.
+ */
+fun readXml(stream: InputStream): List<BubbleEntity> {
+    val bubbles = mutableListOf<BubbleEntity>()
+    val parser: XmlPullParser = Xml.newPullParser()
+    parser.setInput(stream, StandardCharsets.UTF_8.name())
+    XmlUtils.beginDocument(parser, TAG_BUBBLES)
+    val version = parser.getAttributeWithName(ATTR_VERSION)?.toInt()
+    if (version != null && version == CURRENT_VERSION) {
+        val outerDepth = parser.depth
+        while (XmlUtils.nextElementWithin(parser, outerDepth)) {
+            bubbles.add(readXmlEntry(parser) ?: continue)
+        }
+    }
+    return bubbles
+}
+
+private fun readXmlEntry(parser: XmlPullParser): BubbleEntity? {
+    while (parser.eventType != XmlPullParser.START_TAG) { parser.next() }
+    return BubbleEntity(
+            parser.getAttributeWithName(ATTR_USER_ID)?.toInt() ?: return null,
+            parser.getAttributeWithName(ATTR_PACKAGE) ?: return null,
+            parser.getAttributeWithName(ATTR_SHORTCUT_ID) ?: return null,
+            parser.getAttributeWithName(ATTR_KEY) ?: return null,
+            parser.getAttributeWithName(ATTR_DESIRED_HEIGHT)?.toInt() ?: return null,
+            parser.getAttributeWithName(ATTR_DESIRED_HEIGHT_RES_ID)?.toInt() ?: return null,
+            parser.getAttributeWithName(ATTR_TITLE)
+    )
+}
+
+private fun XmlPullParser.getAttributeWithName(name: String): String? {
+    for (i in 0 until attributeCount) {
+        if (getAttributeName(i) == name) return getAttributeValue(i)
+    }
+    return null
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/AlphaOptimizedButton.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/AlphaOptimizedButton.java
new file mode 100644
index 0000000..6f0a61b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/AlphaOptimizedButton.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.Button;
+
+/**
+ * A Button which doesn't have overlapping drawing commands
+ *
+ * This is the copy from SystemUI/statusbar.
+ */
+public class AlphaOptimizedButton extends Button {
+    public AlphaOptimizedButton(Context context) {
+        super(context);
+    }
+
+    public AlphaOptimizedButton(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public AlphaOptimizedButton(Context context, AttributeSet attrs, int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+    }
+
+    public AlphaOptimizedButton(Context context, AttributeSet attrs, int defStyleAttr,
+            int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+    }
+
+    @Override
+    public boolean hasOverlappingRendering() {
+        return false;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/TriangleShape.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/TriangleShape.java
new file mode 100644
index 0000000..7079190
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/TriangleShape.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common;
+
+import android.graphics.Outline;
+import android.graphics.Path;
+import android.graphics.drawable.shapes.PathShape;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Wrapper around {@link PathShape}
+ * that creates a shape with a triangular path (pointing up or down).
+ *
+ * This is the copy from SystemUI/recents.
+ */
+public class TriangleShape extends PathShape {
+    private Path mTriangularPath;
+
+    public TriangleShape(Path path, float stdWidth, float stdHeight) {
+        super(path, stdWidth, stdHeight);
+        mTriangularPath = path;
+    }
+
+    public static TriangleShape create(float width, float height, boolean isPointingUp) {
+        Path triangularPath = new Path();
+        if (isPointingUp) {
+            triangularPath.moveTo(0, height);
+            triangularPath.lineTo(width, height);
+            triangularPath.lineTo(width / 2, 0);
+            triangularPath.close();
+        } else {
+            triangularPath.moveTo(0, 0);
+            triangularPath.lineTo(width / 2, height);
+            triangularPath.lineTo(width, 0);
+            triangularPath.close();
+        }
+        return new TriangleShape(triangularPath, width, height);
+    }
+
+    /** Create an arrow TriangleShape that points to the left or the right */
+    public static TriangleShape createHorizontal(
+            float width, float height, boolean isPointingLeft) {
+        Path triangularPath = new Path();
+        if (isPointingLeft) {
+            triangularPath.moveTo(0, height / 2);
+            triangularPath.lineTo(width, height);
+            triangularPath.lineTo(width, 0);
+            triangularPath.close();
+        } else {
+            triangularPath.moveTo(0, height);
+            triangularPath.lineTo(width, height / 2);
+            triangularPath.lineTo(0, 0);
+            triangularPath.close();
+        }
+        return new TriangleShape(triangularPath, width, height);
+    }
+
+    @Override
+    public void getOutline(@NonNull Outline outline) {
+        outline.setPath(mTriangularPath);
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/AndroidManifest.xml b/libs/WindowManager/Shell/tests/unittest/AndroidManifest.xml
index a8f795e..59d9104 100644
--- a/libs/WindowManager/Shell/tests/unittest/AndroidManifest.xml
+++ b/libs/WindowManager/Shell/tests/unittest/AndroidManifest.xml
@@ -22,6 +22,13 @@
     <application android:debuggable="true" android:largeHeap="true">
         <uses-library android:name="android.test.mock" />
         <uses-library android:name="android.test.runner" />
+
+        <activity android:name=".bubbles.BubblesTestActivity"
+            android:allowEmbedded="true"
+            android:documentLaunchMode="always"
+            android:excludeFromRecents="true"
+            android:exported="false"
+            android:resizeableActivity="true" />
     </application>
 
     <instrumentation
diff --git a/libs/WindowManager/Shell/tests/unittest/res/layout/main.xml b/libs/WindowManager/Shell/tests/unittest/res/layout/main.xml
new file mode 100644
index 0000000..0d09f86
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/res/layout/main.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    >
+    <TextView
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:text="this is a test activity"
+    />
+    <EditText
+        android:layout_height="wrap_content"
+        android:id="@+id/editText1"
+        android:layout_width="match_parent">
+        <requestFocus></requestFocus>
+    </EditText>
+</LinearLayout>
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
similarity index 79%
rename from libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java
rename to libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
index fdebe4e..5bdf831 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.wm.shell.pip;
+package com.android.wm.shell;
 
 import static android.view.Display.DEFAULT_DISPLAY;
 
@@ -24,17 +24,18 @@
 
 import androidx.test.InstrumentationRegistry;
 
+import org.junit.After;
 import org.junit.Before;
 
 /**
- * Base class that does One Handed specific setup.
+ * Base class that does shell test case setup.
  */
-public abstract class PipTestCase {
+public abstract class ShellTestCase {
 
     protected TestableContext mContext;
 
     @Before
-    public void setup() {
+    public void shellSetup() {
         final Context context =
                 InstrumentationRegistry.getInstrumentation().getTargetContext();
         final DisplayManager dm = context.getSystemService(DisplayManager.class);
@@ -47,6 +48,14 @@
                 .adoptShellPermissionIdentity();
     }
 
+    @After
+    public void shellTearDown() {
+        InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .dropShellPermissionIdentity();
+    }
+
     protected Context getContext() {
         return mContext;
     }
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TaskViewTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TaskViewTest.java
new file mode 100644
index 0000000..34f772f
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TaskViewTest.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2020 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.wm.shell;
+
+import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+
+import android.app.ActivityManager;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.SurfaceControl;
+import android.view.SurfaceHolder;
+import android.view.SurfaceSession;
+import android.window.WindowContainerToken;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.common.HandlerExecutor;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.invocation.InvocationOnMock;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class TaskViewTest extends ShellTestCase {
+
+    @Mock
+    TaskView.Listener mViewListener;
+    @Mock
+    ActivityManager.RunningTaskInfo mTaskInfo;
+    @Mock
+    WindowContainerToken mToken;
+    @Mock
+    ShellTaskOrganizer mOrganizer;
+    @Mock
+    HandlerExecutor mExecutor;
+
+    SurfaceSession mSession;
+    SurfaceControl mLeash;
+
+    Context mContext;
+    TaskView mTaskView;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mLeash = new SurfaceControl.Builder(mSession)
+                .setName("test")
+                .build();
+
+        mContext = getContext();
+
+        mTaskInfo = new ActivityManager.RunningTaskInfo();
+        mTaskInfo.token = mToken;
+        mTaskInfo.taskId = 314;
+        mTaskInfo.taskDescription = mock(ActivityManager.TaskDescription.class);
+
+        doAnswer((InvocationOnMock invocationOnMock) -> {
+            final Runnable r = invocationOnMock.getArgument(0);
+            r.run();
+            return null;
+        }).when(mExecutor).execute(any());
+
+        mTaskView = new TaskView(mContext, mOrganizer);
+        mTaskView.setExecutor(mExecutor);
+        mTaskView.setListener(mViewListener);
+    }
+
+    @After
+    public void tearDown() {
+        if (mTaskView != null) {
+            mTaskView.release();
+        }
+    }
+
+    @Test
+    public void testSetPendingListener_throwsException() {
+        TaskView taskView = new TaskView(mContext, mOrganizer);
+        mTaskView.setExecutor(mExecutor);
+        taskView.setListener(mViewListener);
+        try {
+            taskView.setListener(mViewListener);
+        } catch (IllegalStateException e) {
+            // pass
+            return;
+        }
+        fail("Expected IllegalStateException");
+    }
+
+    @Test
+    public void testStartActivity() {
+        ActivityOptions options = ActivityOptions.makeBasic();
+        mTaskView.startActivity(mock(PendingIntent.class), null, options);
+
+        verify(mOrganizer).setPendingLaunchCookieListener(any(), eq(mTaskView));
+        assertThat(options.getLaunchWindowingMode()).isEqualTo(WINDOWING_MODE_MULTI_WINDOW);
+        assertThat(options.getTaskAlwaysOnTop()).isTrue();
+    }
+
+    @Test
+    public void testOnTaskAppeared_noSurface() {
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+
+        verify(mViewListener).onTaskCreated(eq(mTaskInfo.taskId), any());
+        verify(mViewListener, never()).onInitialized();
+        // If there's no surface the task should be made invisible
+        verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(false));
+    }
+
+    @Test
+    public void testOnTaskAppeared_withSurface() {
+        mTaskView.surfaceCreated(mock(SurfaceHolder.class));
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+
+        verify(mViewListener).onTaskCreated(eq(mTaskInfo.taskId), any());
+        verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
+    }
+
+    @Test
+    public void testSurfaceCreated_noTask() {
+        mTaskView.surfaceCreated(mock(SurfaceHolder.class));
+
+        verify(mViewListener).onInitialized();
+        // No task, no visibility change
+        verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
+    }
+
+    @Test
+    public void testSurfaceCreated_withTask() {
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+        mTaskView.surfaceCreated(mock(SurfaceHolder.class));
+
+        verify(mViewListener).onInitialized();
+        verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(true));
+    }
+
+    @Test
+    public void testSurfaceDestroyed_noTask() {
+        SurfaceHolder sh = mock(SurfaceHolder.class);
+        mTaskView.surfaceCreated(sh);
+        mTaskView.surfaceDestroyed(sh);
+
+        verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
+    }
+
+    @Test
+    public void testSurfaceDestroyed_withTask() {
+        SurfaceHolder sh = mock(SurfaceHolder.class);
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+        mTaskView.surfaceCreated(sh);
+        reset(mViewListener);
+        mTaskView.surfaceDestroyed(sh);
+
+        verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(false));
+    }
+
+    @Test
+    public void testOnReleased() {
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+        mTaskView.surfaceCreated(mock(SurfaceHolder.class));
+        mTaskView.release();
+
+        verify(mOrganizer).removeListener(eq(mTaskView));
+        verify(mViewListener).onReleased();
+    }
+
+    @Test
+    public void testOnTaskVanished() {
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+        mTaskView.surfaceCreated(mock(SurfaceHolder.class));
+        mTaskView.onTaskVanished(mTaskInfo);
+
+        verify(mViewListener).onTaskRemovalStarted(eq(mTaskInfo.taskId));
+    }
+
+    @Test
+    public void testOnBackPressedOnTaskRoot() {
+        mTaskView.onTaskAppeared(mTaskInfo, mLeash);
+        mTaskView.onBackPressedOnTaskRoot(mTaskInfo);
+
+        verify(mViewListener).onBackPressedOnTaskRoot(eq(mTaskInfo.taskId));
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleDataTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleDataTest.java
new file mode 100644
index 0000000..7adc411
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleDataTest.java
@@ -0,0 +1,907 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.util.Pair;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.ShellTestCase;
+import com.android.wm.shell.bubbles.BubbleData.TimeSource;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Tests operations and the resulting state managed by BubbleData.
+ * <p>
+ * After each operation to verify, {@link #verifyUpdateReceived()} ensures the listener was called
+ * and captures the Update object received there.
+ * <p>
+ * Other methods beginning with 'assert' access the captured update object and assert on specific
+ * aspects of it.
+ */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class BubbleDataTest extends ShellTestCase {
+
+    private BubbleEntry mEntryA1;
+    private BubbleEntry mEntryA2;
+    private BubbleEntry mEntryA3;
+    private BubbleEntry mEntryB1;
+    private BubbleEntry mEntryB2;
+    private BubbleEntry mEntryB3;
+    private BubbleEntry mEntryC1;
+    private BubbleEntry mEntryInterruptive;
+    private BubbleEntry mEntryDismissed;
+
+    private Bubble mBubbleA1;
+    private Bubble mBubbleA2;
+    private Bubble mBubbleA3;
+    private Bubble mBubbleB1;
+    private Bubble mBubbleB2;
+    private Bubble mBubbleB3;
+    private Bubble mBubbleC1;
+    private Bubble mBubbleInterruptive;
+    private Bubble mBubbleDismissed;
+
+    private BubbleData mBubbleData;
+
+    @Mock
+    private TimeSource mTimeSource;
+    @Mock
+    private BubbleData.Listener mListener;
+    @Mock
+    private PendingIntent mExpandIntent;
+    @Mock
+    private PendingIntent mDeleteIntent;
+    @Mock
+    private BubbleLogger mBubbleLogger;
+
+    @Captor
+    private ArgumentCaptor<BubbleData.Update> mUpdateCaptor;
+
+    @Mock
+    private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
+
+    @Mock
+    private Bubbles.PendingIntentCanceledListener mPendingIntentCanceledListener;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        mEntryA1 = createBubbleEntry(1, "a1", "package.a", null);
+        mEntryA2 = createBubbleEntry(1, "a2", "package.a", null);
+        mEntryA3 = createBubbleEntry(1, "a3", "package.a", null);
+        mEntryB1 = createBubbleEntry(1, "b1", "package.b", null);
+        mEntryB2 = createBubbleEntry(1, "b2", "package.b", null);
+        mEntryB3 = createBubbleEntry(1, "b3", "package.b", null);
+        mEntryC1 = createBubbleEntry(1, "c1", "package.c", null);
+
+        NotificationListenerService.Ranking ranking =
+                mock(NotificationListenerService.Ranking.class);
+        when(ranking.visuallyInterruptive()).thenReturn(true);
+        mEntryInterruptive = createBubbleEntry(1, "interruptive", "package.d", ranking);
+        mBubbleInterruptive = new Bubble(mEntryInterruptive, mSuppressionListener, null);
+
+        mEntryDismissed = createBubbleEntry(1, "dismissed", "package.d", null);
+        mBubbleDismissed = new Bubble(mEntryDismissed, mSuppressionListener, null);
+
+        mBubbleA1 = new Bubble(mEntryA1, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleA2 = new Bubble(mEntryA2, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleA3 = new Bubble(mEntryA3, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleB1 = new Bubble(mEntryB1, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleB2 = new Bubble(mEntryB2, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleB3 = new Bubble(mEntryB3, mSuppressionListener, mPendingIntentCanceledListener);
+        mBubbleC1 = new Bubble(mEntryC1, mSuppressionListener, mPendingIntentCanceledListener);
+
+        mBubbleData = new BubbleData(getContext(), mBubbleLogger);
+
+        // Used by BubbleData to set lastAccessedTime
+        when(mTimeSource.currentTimeMillis()).thenReturn(1000L);
+        mBubbleData.setTimeSource(mTimeSource);
+
+        // Assert baseline starting state
+        assertThat(mBubbleData.hasBubbles()).isFalse();
+        assertThat(mBubbleData.isExpanded()).isFalse();
+        assertThat(mBubbleData.getSelectedBubble()).isNull();
+    }
+
+    @Test
+    public void testAddBubble() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+
+        // Verify
+        verifyUpdateReceived();
+        assertBubbleAdded(mBubbleA1);
+        assertSelectionChangedTo(mBubbleA1);
+    }
+
+    @Test
+    public void testRemoveBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+
+        // Verify
+        verifyUpdateReceived();
+        assertBubbleRemoved(mBubbleA1, Bubbles.DISMISS_USER_GESTURE);
+    }
+
+    @Test
+    public void ifSuppress_hideFlyout() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryUpdated(mBubbleC1, /* suppressFlyout */ true, /* showInShade */
+                true);
+
+        // Verify
+        verifyUpdateReceived();
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertThat(update.addedBubble.showFlyout()).isFalse();
+    }
+
+    @Test
+    public void ifInterruptiveAndNotSuppressed_thenShowFlyout() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryUpdated(mBubbleInterruptive,
+                false /* suppressFlyout */, true  /* showInShade */);
+
+        // Verify
+        verifyUpdateReceived();
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertThat(update.addedBubble.showFlyout()).isTrue();
+    }
+
+    @Test
+    public void sameUpdate_InShade_thenHideFlyout() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryUpdated(mBubbleC1, false /* suppressFlyout */,
+                true /* showInShade */);
+        verifyUpdateReceived();
+
+        mBubbleData.notificationEntryUpdated(mBubbleC1, false /* suppressFlyout */,
+                true /* showInShade */);
+        verifyUpdateReceived();
+
+        // Verify
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertThat(update.updatedBubble.showFlyout()).isFalse();
+    }
+
+    @Test
+    public void sameUpdate_NotInShade_NotVisuallyInterruptive_dontShowFlyout() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryUpdated(mBubbleDismissed, false /* suppressFlyout */,
+                true /* showInShade */);
+        verifyUpdateReceived();
+
+        // Suppress the notif / make it look dismissed
+        mBubbleDismissed.setSuppressNotification(true);
+
+        mBubbleData.notificationEntryUpdated(mBubbleDismissed, false /* suppressFlyout */,
+                true /* showInShade */);
+        verifyUpdateReceived();
+
+        // Verify
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertThat(update.updatedBubble.showFlyout()).isFalse();
+    }
+
+    //
+    // Overflow
+    //
+
+    /**
+     * Verifies that when the bubble stack reaches its maximum, the oldest bubble is overflowed.
+     */
+    @Test
+    public void testOverflow_add_stackAtMaxBubbles_overflowsOldest() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        sendUpdatedEntryAtTime(mEntryB1, 4000);
+        sendUpdatedEntryAtTime(mEntryB2, 5000);
+        mBubbleData.setListener(mListener);
+
+        sendUpdatedEntryAtTime(mEntryC1, 6000);
+        verifyUpdateReceived();
+        assertBubbleRemoved(mBubbleA1, Bubbles.DISMISS_AGED);
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA1));
+
+        Bubble bubbleA1 = mBubbleData.getOrCreateBubble(mEntryA1, null /* persistedBubble */);
+        bubbleA1.markUpdatedAt(7000L);
+        mBubbleData.notificationEntryUpdated(bubbleA1, false /* suppressFlyout*/,
+                true /* showInShade */);
+        verifyUpdateReceived();
+        assertBubbleRemoved(mBubbleA2, Bubbles.DISMISS_AGED);
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
+    }
+
+    /**
+     * Verifies that once the number of overflowed bubbles reaches its maximum, the oldest
+     * overflow bubble is removed.
+     */
+    @Test
+    public void testOverflow_maxReached_bubbleRemoved() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        mBubbleData.setListener(mListener);
+
+        mBubbleData.setMaxOverflowBubbles(1);
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA1));
+
+        // Overflow max of 1 is reached; A1 is oldest, so it gets removed
+        mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
+    }
+
+    /**
+     * Verifies that overflow bubbles are canceled on notif entry removal.
+     */
+    @Test
+    public void testOverflow_notifCanceled_removesOverflowBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        sendUpdatedEntryAtTime(mEntryB1, 4000);
+        sendUpdatedEntryAtTime(mEntryB2, 5000);
+        sendUpdatedEntryAtTime(mEntryB3, 6000); // [A2, A3, B1, B2, B3], overflow: [A1]
+        sendUpdatedEntryAtTime(mEntryC1, 7000); // [A3, B1, B2, B3, C1], overflow: [A2, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_NOTIF_CANCEL);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_GROUP_CANCELLED);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of());
+    }
+
+    // COLLAPSED / ADD
+
+    /**
+     * Verifies that new bubbles insert to the left when collapsed.
+     * <p>
+     * Placement within the list is based on {@link Bubble#getLastActivity()}, descending
+     * order (with most recent first).
+     */
+    @Test
+    public void test_collapsed_addBubble() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleB1, mBubbleA1);
+
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleB2, mBubbleB1, mBubbleA1);
+
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA2, mBubbleB2, mBubbleB1, mBubbleA1);
+    }
+
+    /**
+     * Verifies that new bubbles become the selected bubble when they appear when the stack is in
+     * the collapsed state.
+     *
+     * @see #test_collapsed_updateBubble_selectionChanges()
+     */
+    @Test
+    public void test_collapsed_addBubble_selectionChanges() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleA1);
+
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleB1);
+
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleB2);
+
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleA2);
+    }
+
+    // COLLAPSED / REMOVE
+
+    /**
+     * Verifies order of bubbles after a removal.
+     */
+    @Test
+    public void test_collapsed_removeBubble_sort() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        // TODO: this should fail if things work as I expect them to?
+        assertOrderChangedTo(mBubbleB2, mBubbleB1, mBubbleA1);
+    }
+
+    /**
+     * Verifies that onOrderChanged is not called when a bubble is removed if the removal does not
+     * cause other bubbles to change position.
+     */
+    @Test
+    public void test_collapsed_removeOldestBubble_doesNotCallOnOrderChanged() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+    }
+
+    /**
+     * Verifies that when the selected bubble is removed with the stack in the collapsed state,
+     * the selection moves to the next most-recently updated bubble.
+     */
+    @Test
+    public void test_collapsed_removeBubble_selectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_NOTIF_CANCEL);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleB2);
+    }
+
+    // COLLAPSED / UPDATE
+
+    /**
+     * Verifies that bubble ordering changes with updates while the stack is in the
+     * collapsed state.
+     */
+    @Test
+    public void test_collapsed_updateBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 5000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleB1, mBubbleA2, mBubbleB2, mBubbleA1);
+
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA1, mBubbleB1, mBubbleA2, mBubbleB2);
+    }
+
+    /**
+     * Verifies that selection tracks the most recently updated bubble while in the collapsed state.
+     */
+    @Test
+    public void test_collapsed_updateBubble_selectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 5000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleB1);
+
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleA1);
+    }
+
+    /**
+     * Verifies that when a non visually interruptive update occurs, that the selection does not
+     * change.
+     */
+    @Test
+    public void test_notVisuallyInterruptive_updateBubble_selectionDoesntChange() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA2);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 5000, false /* isVisuallyInterruptive */);
+        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA2);
+    }
+
+    /**
+     * Verifies that a request to expand the stack has no effect if there are no bubbles.
+     */
+    @Test
+    public void test_collapsed_expansion_whenEmpty_doesNothing() {
+        assertThat(mBubbleData.hasBubbles()).isFalse();
+        mBubbleData.setListener(mListener);
+
+        changeExpandedStateAtTime(true, 2000L);
+        verifyZeroInteractions(mListener);
+    }
+
+    /**
+     * Verifies that removing the last bubble clears the selected bubble and collapses the stack.
+     */
+    @Test
+    public void test_collapsed_removeLastBubble_clearsSelectedBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+
+        // Verify the selection was cleared.
+        verifyUpdateReceived();
+        assertThat(mBubbleData.isExpanded()).isFalse();
+        assertThat(mBubbleData.getSelectedBubble()).isNull();
+    }
+
+    // EXPANDED / ADD / UPDATE
+
+    /**
+     * Verifies that bubbles are added at the front of the stack.
+     * <p>
+     * Placement within the list is based on {@link Bubble#getLastActivity()}, descending
+     * order (with most recent first).
+     *
+     * @see #test_collapsed_addBubble()
+     */
+    @Test
+    public void test_expanded_addBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000); // [B1, A2, A1]
+        changeExpandedStateAtTime(true, 4000L); // B1 marked updated at 4000L
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryC1, 4000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleC1, mBubbleB1, mBubbleA2, mBubbleA1);
+    }
+
+    /**
+     * Verifies that updates to bubbles while expanded do not result in any change to sorting
+     * of bubbles.
+     */
+    @Test
+    public void test_expanded_updateBubble_noChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 4000);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+    }
+
+    /**
+     * Verifies that updates to bubbles while expanded do not result in any change to selection.
+     *
+     * @see #test_collapsed_addBubble_selectionChanges()
+     */
+    @Test
+    public void test_expanded_updateBubble_noSelectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+
+        sendUpdatedEntryAtTime(mEntryA2, 7000);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+
+        sendUpdatedEntryAtTime(mEntryB1, 8000);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+    }
+
+    // EXPANDED / REMOVE
+
+    /**
+     * Verifies that removing a bubble while expanded does not result in reordering of bubbles.
+     *
+     * @see #test_collapsed_addBubble()
+     */
+    @Test
+    public void test_expanded_removeBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, A2, B1, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryB2.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleA1);
+    }
+
+    /**
+     * Verifies that removing the selected bubble while expanded causes another bubble to become
+     * selected. The replacement selection is the bubble which appears at the same index as the
+     * previous one, or the previous index if this was the last position.
+     *
+     * @see #test_collapsed_addBubble()
+     */
+    @Test
+    public void test_expanded_removeBubble_selectionChanges_whenSelectedRemoved() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setSelectedBubble(mBubbleA2);  // [B2, A2^, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleB1);
+
+        mBubbleData.dismissBubbleWithKey(mEntryB1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertSelectionChangedTo(mBubbleA1);
+    }
+
+    @Test
+    public void test_expandAndCollapse_callsOnExpandedChanged() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        changeExpandedStateAtTime(true, 3000L);
+        verifyUpdateReceived();
+        assertExpandedChangedTo(true);
+
+        changeExpandedStateAtTime(false, 4000L);
+        verifyUpdateReceived();
+        assertExpandedChangedTo(false);
+    }
+
+    /**
+     * Verifies that transitions between the collapsed and expanded state maintain sorting and
+     * grouping rules.
+     * <p>
+     * While collapsing, sorting is applied since no sorting happens while expanded. The resulting
+     * state is the new expanded ordering. This state is saved and restored if possible when next
+     * expanded.
+     * <p>
+     * When the stack transitions to the collapsed state, the selected bubble is brought to the top.
+     * <p>
+     * When the stack transitions back to the expanded state, this new order is kept as is.
+     */
+    @Test
+    public void test_expansionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L); // [B2=4000, A2=3000, B1=2000, A1=1000]
+        sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, A2=3000, B1=6000, A1=1000]
+        setCurrentTime(7000);
+        mBubbleData.setSelectedBubble(mBubbleA2);
+        mBubbleData.setListener(mListener);
+        assertThat(mBubbleData.getBubbles()).isEqualTo(
+                ImmutableList.of(mBubbleB2, mBubbleA2, mBubbleB1, mBubbleA1));
+
+        // Test
+
+        // At this point, B1 has been updated but sorting has not been changed because the
+        // stack is expanded. When next collapsed, sorting will be applied and saved, just prior
+        // to moving the selected bubble to the top (first).
+        //
+        // In this case, the expected re-expand state will be: [A2^, B1, B2, A1]
+        //
+        // collapse -> selected bubble (A2) moves first.
+        changeExpandedStateAtTime(false, 8000L);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleB2, mBubbleA1);
+    }
+
+    /**
+     * When a change occurs while collapsed (any update, add, remove), the previous expanded
+     * order becomes invalidated, the stack is resorted and will reflect that when next expanded.
+     */
+    @Test
+    public void test_expansionChanges_withUpdatesWhileCollapsed() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L); // [B2=4000, A2=3000,  B1=2000, A1=1000]
+        sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, A2=3000,  B1=6000, A1=1000]
+        setCurrentTime(7000);
+        mBubbleData.setSelectedBubble(mBubbleA2); // [B2, A2^, B1, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+
+        // At this point, B1 has been updated but sorting has not been changed because the
+        // stack is expanded. When next collapsed, sorting will be applied and saved, just prior
+        // to moving the selected bubble to the top (first).
+        //
+        // In this case, the expected re-expand state will be: [A2^, B1, B2, A1]
+        //
+        // That state is restored as long as no changes occur (add/remove/update) while in
+        // the collapsed state.
+        //
+        // collapse -> selected bubble (A2) moves first.
+        changeExpandedStateAtTime(false, 8000L);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleB2, mBubbleA1);
+
+        // An update occurs, which causes sorting, and this invalidates the previously saved order.
+        sendUpdatedEntryAtTime(mEntryA1, 9000);
+        verifyUpdateReceived();
+        assertOrderChangedTo(mBubbleA1, mBubbleA2, mBubbleB1, mBubbleB2);
+
+        // No order changes when expanding because the new sorted order remains.
+        changeExpandedStateAtTime(true, 10000L);
+        verifyUpdateReceived();
+        assertOrderNotChanged();
+    }
+
+    @Test
+    public void test_expanded_removeLastBubble_collapsesStack() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        changeExpandedStateAtTime(true, 2000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertExpandedChangedTo(false);
+    }
+
+    private void verifyUpdateReceived() {
+        verify(mListener).applyUpdate(mUpdateCaptor.capture());
+        reset(mListener);
+    }
+
+    private void assertBubbleAdded(Bubble expected) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("addedBubble").that(update.addedBubble).isEqualTo(expected);
+    }
+
+    private void assertBubbleRemoved(Bubble expected, @BubbleController.DismissReason int reason) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("removedBubbles").that(update.removedBubbles)
+                .isEqualTo(ImmutableList.of(Pair.create(expected, reason)));
+    }
+
+    private void assertOrderNotChanged() {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("orderChanged").that(update.orderChanged).isFalse();
+    }
+
+    private void assertOrderChangedTo(Bubble... order) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("orderChanged").that(update.orderChanged).isTrue();
+        assertWithMessage("bubble order").that(update.bubbles)
+                .isEqualTo(ImmutableList.copyOf(order));
+    }
+
+    private void assertSelectionNotChanged() {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("selectionChanged").that(update.selectionChanged).isFalse();
+    }
+
+    private void assertSelectionChangedTo(Bubble bubble) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("selectionChanged").that(update.selectionChanged).isTrue();
+        assertWithMessage("selectedBubble").that(update.selectedBubble).isEqualTo(bubble);
+    }
+
+    private void assertSelectionCleared() {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("selectionChanged").that(update.selectionChanged).isTrue();
+        assertWithMessage("selectedBubble").that(update.selectedBubble).isNull();
+    }
+
+    private void assertExpandedChangedTo(boolean expected) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertWithMessage("expandedChanged").that(update.expandedChanged).isTrue();
+        assertWithMessage("expanded").that(update.expanded).isEqualTo(expected);
+    }
+
+    private void assertOverflowChangedTo(ImmutableList<Bubble> bubbles) {
+        BubbleData.Update update = mUpdateCaptor.getValue();
+        assertThat(update.overflowBubbles).isEqualTo(bubbles);
+    }
+
+
+    private BubbleEntry createBubbleEntry(int userId, String notifKey, String packageName,
+            NotificationListenerService.Ranking ranking) {
+        return createBubbleEntry(userId, notifKey, packageName, ranking, 1000);
+    }
+
+    private void setPostTime(BubbleEntry entry, long postTime) {
+        when(entry.getStatusBarNotification().getPostTime()).thenReturn(postTime);
+    }
+
+    /**
+     * No ExpandableNotificationRow is required to test BubbleData. This setup is all that is
+     * required for BubbleData functionality and verification. NotificationTestHelper is used only
+     * as a convenience to create a Notification w/BubbleMetadata.
+     */
+    private BubbleEntry createBubbleEntry(int userId, String notifKey, String packageName,
+            NotificationListenerService.Ranking ranking, long postTime) {
+        // BubbleMetadata
+        Notification.BubbleMetadata bubbleMetadata = new Notification.BubbleMetadata.Builder(
+                mExpandIntent, Icon.createWithResource("", 0))
+                .setDeleteIntent(mDeleteIntent)
+                .build();
+        // Notification -> BubbleMetadata
+        Notification notification = mock(Notification.class);
+        notification.setBubbleMetadata(bubbleMetadata);
+
+        // Notification -> extras
+        notification.extras = new Bundle();
+
+        // StatusBarNotification
+        StatusBarNotification sbn = mock(StatusBarNotification.class);
+        when(sbn.getKey()).thenReturn(notifKey);
+        when(sbn.getUser()).thenReturn(new UserHandle(userId));
+        when(sbn.getPackageName()).thenReturn(packageName);
+        when(sbn.getPostTime()).thenReturn(postTime);
+        when(sbn.getNotification()).thenReturn(notification);
+
+        // NotificationEntry -> StatusBarNotification -> Notification -> BubbleMetadata
+        return new BubbleEntry(sbn, ranking, true, false, false, false);
+    }
+
+    private void setCurrentTime(long time) {
+        when(mTimeSource.currentTimeMillis()).thenReturn(time);
+    }
+
+    private void sendUpdatedEntryAtTime(BubbleEntry entry, long postTime) {
+        sendUpdatedEntryAtTime(entry, postTime, true /* visuallyInterruptive */);
+    }
+
+    private void sendUpdatedEntryAtTime(BubbleEntry entry, long postTime,
+            boolean visuallyInterruptive) {
+        setPostTime(entry, postTime);
+        // BubbleController calls this:
+        Bubble b = mBubbleData.getOrCreateBubble(entry, null /* persistedBubble */);
+        b.setVisuallyInterruptiveForTest(visuallyInterruptive);
+        // And then this
+        mBubbleData.notificationEntryUpdated(b, false /* suppressFlyout*/,
+                true /* showInShade */);
+    }
+
+    private void changeExpandedStateAtTime(boolean shouldBeExpanded, long time) {
+        setCurrentTime(time);
+        mBubbleData.setExpanded(shouldBeExpanded);
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleFlyoutViewTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleFlyoutViewTest.java
new file mode 100644
index 0000000..5b77e4a
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleFlyoutViewTest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotSame;
+
+import static org.mockito.Mockito.verify;
+
+import android.graphics.Color;
+import android.graphics.PointF;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.ShellTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class BubbleFlyoutViewTest extends ShellTestCase {
+    private BubbleFlyoutView mFlyout;
+    private TextView mFlyoutText;
+    private TextView mSenderName;
+    private float[] mDotCenter = new float[2];
+    private Bubble.FlyoutMessage mFlyoutMessage;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        mFlyoutMessage = new Bubble.FlyoutMessage();
+        mFlyoutMessage.senderName = "Josh";
+        mFlyoutMessage.message = "Hello";
+
+        mFlyout = new BubbleFlyoutView(getContext());
+
+        mFlyoutText = mFlyout.findViewById(R.id.bubble_flyout_text);
+        mSenderName = mFlyout.findViewById(R.id.bubble_flyout_name);
+        mDotCenter[0] = 30;
+        mDotCenter[1] = 30;
+    }
+
+    @Test
+    public void testShowFlyout_isVisible() {
+        mFlyout.setupFlyoutStartingAsDot(
+                mFlyoutMessage,
+                new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
+                false);
+        mFlyout.setVisibility(View.VISIBLE);
+
+        assertEquals("Hello", mFlyoutText.getText());
+        assertEquals("Josh", mSenderName.getText());
+        assertEquals(View.VISIBLE, mFlyout.getVisibility());
+    }
+
+    @Test
+    public void testFlyoutHide_runsCallback() {
+        Runnable after = Mockito.mock(Runnable.class);
+        mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
+                new PointF(100, 100), 500, true, Color.WHITE, null, after, mDotCenter,
+                false);
+        mFlyout.hideFlyout();
+
+        verify(after).run();
+    }
+
+    @Test
+    public void testSetCollapsePercent() {
+        mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
+                new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
+                false);
+        mFlyout.setVisibility(View.VISIBLE);
+
+        mFlyout.setCollapsePercent(1f);
+        assertEquals(0f, mFlyoutText.getAlpha(), 0.01f);
+        assertNotSame(0f, mFlyoutText.getTranslationX()); // Should have moved to collapse.
+
+        mFlyout.setCollapsePercent(0f);
+        assertEquals(1f, mFlyoutText.getAlpha(), 0.01f);
+        assertEquals(0f, mFlyoutText.getTranslationX());
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTest.java
new file mode 100644
index 0000000..0693052
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.service.notification.StatusBarNotification;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.ShellTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class BubbleTest extends ShellTestCase {
+    @Mock
+    private Notification mNotif;
+    @Mock
+    private StatusBarNotification mSbn;
+
+    private BubbleEntry mBubbleEntry;
+    private Bundle mExtras;
+    private Bubble mBubble;
+
+    @Mock
+    private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mExtras = new Bundle();
+        mNotif.extras = mExtras;
+
+        Intent target = new Intent(mContext, BubblesTestActivity.class);
+        Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder(
+                PendingIntent.getActivity(mContext, 0, target, 0),
+                        Icon.createWithResource(mContext, R.drawable.bubble_ic_create_bubble))
+                .build();
+        when(mSbn.getNotification()).thenReturn(mNotif);
+        when(mNotif.getBubbleMetadata()).thenReturn(metadata);
+        when(mSbn.getKey()).thenReturn("mock");
+        mBubbleEntry = new BubbleEntry(mSbn, null, true, false, false, false);
+        mBubble = new Bubble(mBubbleEntry, mSuppressionListener, null);
+    }
+
+    @Test
+    public void testGetUpdateMessage_default() {
+        final String msg = "Hello there!";
+        doReturn(Notification.Style.class).when(mNotif).getNotificationStyle();
+        mExtras.putCharSequence(Notification.EXTRA_TEXT, msg);
+        assertEquals(msg, Bubble.extractFlyoutMessage(mBubbleEntry).message);
+    }
+
+    @Test
+    public void testGetUpdateMessage_bigText() {
+        final String msg = "A big hello there!";
+        doReturn(Notification.BigTextStyle.class).when(mNotif).getNotificationStyle();
+        mExtras.putCharSequence(Notification.EXTRA_TEXT, "A small hello there.");
+        mExtras.putCharSequence(Notification.EXTRA_BIG_TEXT, msg);
+
+        // Should be big text, not the small text.
+        assertEquals(msg, Bubble.extractFlyoutMessage(mBubbleEntry).message);
+    }
+
+    @Test
+    public void testGetUpdateMessage_media() {
+        doReturn(Notification.MediaStyle.class).when(mNotif).getNotificationStyle();
+
+        // Media notifs don't get update messages.
+        assertNull(Bubble.extractFlyoutMessage(mBubbleEntry).message);
+    }
+
+    @Test
+    public void testGetUpdateMessage_inboxStyle() {
+        doReturn(Notification.InboxStyle.class).when(mNotif).getNotificationStyle();
+        mExtras.putCharSequenceArray(
+                Notification.EXTRA_TEXT_LINES,
+                new CharSequence[]{
+                        "How do you feel about tests?",
+                        "They're okay, I guess.",
+                        "I hate when they're flaky.",
+                        "Really? I prefer them that way."});
+
+        // Should be the last one only.
+        assertEquals("Really? I prefer them that way.",
+                Bubble.extractFlyoutMessage(mBubbleEntry).message);
+    }
+
+    @Test
+    public void testGetUpdateMessage_messagingStyle() {
+        doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
+        mExtras.putParcelableArray(
+                Notification.EXTRA_MESSAGES,
+                new Bundle[]{
+                        new Notification.MessagingStyle.Message(
+                                "Hello", 0, "Josh").toBundle(),
+                        new Notification.MessagingStyle.Message(
+                                "Oh, hello!", 0, "Mady").toBundle()});
+
+        // Should be the last one only.
+        assertEquals("Oh, hello!", Bubble.extractFlyoutMessage(mBubbleEntry).message);
+        assertEquals("Mady", Bubble.extractFlyoutMessage(mBubbleEntry).senderName);
+    }
+
+    @Test
+    public void testSuppressionListener_change_notified() {
+        assertThat(mBubble.showInShade()).isTrue();
+
+        mBubble.setSuppressNotification(true);
+
+        assertThat(mBubble.showInShade()).isFalse();
+
+        verify(mSuppressionListener).onBubbleNotificationSuppressionChange(mBubble);
+    }
+
+    @Test
+    public void testSuppressionListener_noChange_doesntNotify() {
+        assertThat(mBubble.showInShade()).isTrue();
+
+        mBubble.setSuppressNotification(false);
+
+        verify(mSuppressionListener, never()).onBubbleNotificationSuppressionChange(any());
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesTestActivity.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesTestActivity.java
new file mode 100644
index 0000000..d5fbe55
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesTestActivity.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.android.wm.shell.R;
+
+/**
+ * Referenced by NotificationTestHelper#makeBubbleMetadata
+ */
+public class BubblesTestActivity extends Activity {
+
+    public static final String BUBBLE_ACTIVITY_OPENED = "BUBBLE_ACTIVITY_OPENED";
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.main);
+
+        Intent i = new Intent(BUBBLE_ACTIVITY_OPENED);
+        sendBroadcast(i);
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java
new file mode 100644
index 0000000..9c4f341
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationControllerTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.annotation.SuppressLint;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Insets;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.testing.AndroidTestingRunner;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.bubbles.BubblePositioner;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Spy;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
+
+    private int mDisplayWidth = 500;
+    private int mDisplayHeight = 1000;
+    private int mExpandedViewPadding = 10;
+
+    private Runnable mOnBubbleAnimatedOutAction = mock(Runnable.class);
+    @Spy
+    ExpandedAnimationController mExpandedController;
+
+    private int mStackOffset;
+    private PointF mExpansionPoint;
+
+    @SuppressLint("VisibleForTests")
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        BubblePositioner positioner = new BubblePositioner(getContext(), mock(WindowManager.class));
+        positioner.update(Configuration.ORIENTATION_PORTRAIT,
+                Insets.of(0, 0, 0, 0),
+                new Rect(0, 0, mDisplayWidth, mDisplayHeight));
+        mExpandedController = new ExpandedAnimationController(positioner, mExpandedViewPadding,
+                mOnBubbleAnimatedOutAction);
+
+        addOneMoreThanBubbleLimitBubbles();
+        mLayout.setActiveController(mExpandedController);
+
+        Resources res = mLayout.getResources();
+        mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
+        mExpansionPoint = new PointF(100, 100);
+    }
+
+    @Test
+    @Ignore
+    public void testExpansionAndCollapse() throws InterruptedException {
+        Runnable afterExpand = mock(Runnable.class);
+        mExpandedController.expandFromStack(afterExpand);
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        testBubblesInCorrectExpandedPositions();
+        verify(afterExpand).run();
+
+        Runnable afterCollapse = mock(Runnable.class);
+        mExpandedController.collapseBackToStack(mExpansionPoint, afterCollapse);
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        testStackedAtPosition(mExpansionPoint.x, mExpansionPoint.y, -1);
+        verify(afterExpand).run();
+    }
+
+    @Test
+    @Ignore
+    public void testOnChildAdded() throws InterruptedException {
+        expand();
+
+        // Add another new view and wait for its animation.
+        final View newView = new FrameLayout(getContext());
+        mLayout.addView(newView, 0);
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        testBubblesInCorrectExpandedPositions();
+    }
+
+    @Test
+    @Ignore
+    public void testOnChildRemoved() throws InterruptedException {
+        expand();
+
+        // Remove some views and see if the remaining child views still pass the expansion test.
+        mLayout.removeView(mViews.get(0));
+        mLayout.removeView(mViews.get(3));
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+        testBubblesInCorrectExpandedPositions();
+    }
+
+    /** Expand the stack and wait for animations to finish. */
+    private void expand() throws InterruptedException {
+        mExpandedController.expandFromStack(mock(Runnable.class));
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+    }
+
+    /** Check that children are in the correct positions for being stacked. */
+    private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
+        // Make sure the rest of the stack moved again, including the first bubble not moving, and
+        // is stacked to the right now that we're on the right side of the screen.
+        for (int i = 0; i < mLayout.getChildCount(); i++) {
+            assertEquals(x + i * offsetMultiplier * mStackOffset,
+                    mLayout.getChildAt(i).getTranslationX(), 2f);
+            assertEquals(y, mLayout.getChildAt(i).getTranslationY(), 2f);
+            assertEquals(1f, mLayout.getChildAt(i).getAlpha(), .01f);
+        }
+    }
+
+    /** Check that children are in the correct positions for being expanded. */
+    private void testBubblesInCorrectExpandedPositions() {
+        // Check all the visible bubbles to see if they're in the right place.
+        for (int i = 0; i < mLayout.getChildCount(); i++) {
+            float expectedPosition = mExpandedController.getBubbleXOrYForOrientation(i);
+            assertEquals(expectedPosition,
+                    mLayout.getChildAt(i).getTranslationX(),
+                    2f);
+            assertEquals(expectedPosition,
+                    mLayout.getChildAt(i).getTranslationY(), 2f);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTest.java
new file mode 100644
index 0000000..c4edbb2
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTest.java
@@ -0,0 +1,507 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.os.SystemClock;
+import android.testing.AndroidTestingRunner;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+import androidx.test.filters.SmallTest;
+
+import com.google.android.collect.Sets;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+/** Tests the PhysicsAnimationLayout itself, with a basic test animation controller. */
+public class PhysicsAnimationLayoutTest extends PhysicsAnimationLayoutTestCase {
+    static final float TEST_TRANSLATION_X_OFFSET = 15f;
+
+    @Spy
+    private TestableAnimationController mTestableController = new TestableAnimationController();
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        // By default, use translation animations, chain the X animations with the default
+        // offset, and don't actually remove views immediately (since most implementations will wait
+        // to animate child views out before actually removing them).
+        mTestableController.setAnimatedProperties(Sets.newHashSet(
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y));
+        mTestableController.setChainedProperties(Sets.newHashSet(DynamicAnimation.TRANSLATION_X));
+        mTestableController.setOffsetForProperty(
+                DynamicAnimation.TRANSLATION_X, TEST_TRANSLATION_X_OFFSET);
+        mTestableController.setRemoveImmediately(false);
+    }
+
+    @Test
+    @Ignore
+    public void testHierarchyChanges() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        // Make sure the controller was notified of all the views we added.
+        for (View mView : mViews) {
+            Mockito.verify(mTestableController).onChildAdded(mView, 0);
+        }
+
+        // Remove some views and ensure the controller was notified, with the proper indices.
+        mTestableController.setRemoveImmediately(true);
+        mLayout.removeView(mViews.get(1));
+        mLayout.removeView(mViews.get(2));
+        Mockito.verify(mTestableController).onChildRemoved(
+                eq(mViews.get(1)), eq(1), any());
+        Mockito.verify(mTestableController).onChildRemoved(
+                eq(mViews.get(2)), eq(1), any());
+
+        // Make sure we still get view added notifications after doing some removals.
+        final View newBubble = new FrameLayout(mContext);
+        mLayout.addView(newBubble, 0);
+        Mockito.verify(mTestableController).onChildAdded(newBubble, 0);
+    }
+
+    @Test
+    @Ignore
+    public void testUpdateValueNotChained() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        // Don't chain any values.
+        mTestableController.setChainedProperties(Sets.newHashSet());
+
+        // Child views should not be translated.
+        assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+        assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+
+        // Animate the first child's translation X.
+        final CountDownLatch animLatch = new CountDownLatch(1);
+
+        mTestableController
+                .animationForChildAtIndex(0)
+                .translationX(100)
+                .start(animLatch::countDown);
+        animLatch.await(1, TimeUnit.SECONDS);
+
+        // Ensure that the first view has been translated, but not the second one.
+        assertEquals(100, mLayout.getChildAt(0).getTranslationX(), .1f);
+        assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+    }
+
+    @Test
+    @Ignore
+    public void testUpdateValueXChained() throws InterruptedException {
+        testChainedTranslationAnimations();
+    }
+
+    @Test
+    @Ignore
+    public void testSetEndActions() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+        mTestableController.setChainedProperties(Sets.newHashSet());
+
+        final CountDownLatch xLatch = new CountDownLatch(1);
+        Runnable xEndAction = Mockito.spy(new Runnable() {
+
+            @Override
+            public void run() {
+                xLatch.countDown();
+            }
+        });
+
+        final CountDownLatch yLatch = new CountDownLatch(1);
+        Runnable yEndAction = Mockito.spy(new Runnable() {
+
+            @Override
+            public void run() {
+                yLatch.countDown();
+            }
+        });
+
+        // Set end listeners for both x and y.
+        mTestableController.setEndActionForProperty(xEndAction, DynamicAnimation.TRANSLATION_X);
+        mTestableController.setEndActionForProperty(yEndAction, DynamicAnimation.TRANSLATION_Y);
+
+        // Animate x, and wait for it to finish.
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100)
+                .start();
+
+        xLatch.await();
+        yLatch.await(1, TimeUnit.SECONDS);
+
+        // Make sure the x end listener was called only one time, and the y listener was never
+        // called since we didn't animate y. Wait 1 second after the original animation end trigger
+        // to make sure it doesn't get called again.
+        Mockito.verify(xEndAction, Mockito.after(1000).times(1)).run();
+        Mockito.verify(yEndAction, Mockito.after(1000).never()).run();
+    }
+
+    @Test
+    @Ignore
+    public void testRemoveEndListeners() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+        mTestableController.setChainedProperties(Sets.newHashSet());
+
+        final CountDownLatch xLatch = new CountDownLatch(1);
+        Runnable xEndListener = Mockito.spy(new Runnable() {
+
+            @Override
+            public void run() {
+                xLatch.countDown();
+            }
+        });
+
+        // Set the end listener.
+        mTestableController.setEndActionForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
+
+        // Animate x, and wait for it to finish.
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100)
+                .start();
+        xLatch.await();
+
+        InOrder endListenerCalls = inOrder(xEndListener);
+        endListenerCalls.verify(xEndListener, Mockito.times(1)).run();
+
+        // Animate X again, remove the end listener.
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(1000)
+                .start();
+        mTestableController.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
+        xLatch.await(1, TimeUnit.SECONDS);
+
+        // Make sure the end listener was not called.
+        endListenerCalls.verifyNoMoreInteractions();
+    }
+
+    @Test
+    @Ignore
+    public void testSetController() throws InterruptedException {
+        // Add the bubbles, then set the controller, to make sure that a controller added to an
+        // already-initialized view works correctly.
+        addOneMoreThanBubbleLimitBubbles();
+        mLayout.setActiveController(mTestableController);
+        testChainedTranslationAnimations();
+
+        TestableAnimationController secondController =
+                Mockito.spy(new TestableAnimationController());
+        secondController.setAnimatedProperties(Sets.newHashSet(
+                DynamicAnimation.SCALE_X, DynamicAnimation.SCALE_Y));
+        secondController.setChainedProperties(Sets.newHashSet(
+                DynamicAnimation.SCALE_X));
+        secondController.setOffsetForProperty(
+                DynamicAnimation.SCALE_X, 10f);
+        secondController.setRemoveImmediately(true);
+
+        mLayout.setActiveController(secondController);
+        mTestableController.animationForChildAtIndex(0)
+                .scaleX(1.5f)
+                .start();
+
+        waitForPropertyAnimations(DynamicAnimation.SCALE_X);
+
+        // Make sure we never asked the original controller about any SCALE animations, that would
+        // mean the controller wasn't switched over properly.
+        Mockito.verify(mTestableController, Mockito.never())
+                .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
+        Mockito.verify(mTestableController, Mockito.never())
+                .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
+
+        // Make sure we asked the new controller about its animated properties, and configuration
+        // options.
+        Mockito.verify(secondController, Mockito.atLeastOnce())
+                .getAnimatedProperties();
+        Mockito.verify(secondController, Mockito.atLeastOnce())
+                .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
+        Mockito.verify(secondController, Mockito.atLeastOnce())
+                .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
+
+        mLayout.setActiveController(mTestableController);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+        // Make sure we never asked the second controller about the TRANSLATION_X animation.
+        Mockito.verify(secondController, Mockito.never())
+                .getNextAnimationInChain(eq(DynamicAnimation.TRANSLATION_X), anyInt());
+        Mockito.verify(secondController, Mockito.never())
+                .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.TRANSLATION_X));
+
+    }
+
+    @Test
+    @Ignore
+    public void testArePropertiesAnimating() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        assertFalse(mLayout.arePropertiesAnimating(
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
+
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
+
+        // Wait for the animations to get underway.
+        SystemClock.sleep(50);
+
+        assertTrue(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_X));
+        assertFalse(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_Y));
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+        assertFalse(mLayout.arePropertiesAnimating(
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
+    }
+
+    @Test
+    @Ignore
+    public void testCancelAllAnimations() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        mTestableController.animationForChildAtIndex(0)
+                .position(1000, 1000)
+                .start();
+
+        mLayout.cancelAllAnimations();
+
+        // Animations should be somewhere before their end point.
+        assertTrue(mViews.get(0).getTranslationX() < 1000);
+        assertTrue(mViews.get(0).getTranslationY() < 1000);
+    }
+
+    /** Standard test of chained translation animations. */
+    private void testChainedTranslationAnimations() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+        assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+        for (int i = 0; i < mLayout.getChildCount(); i++) {
+            assertEquals(
+                    100 + i * TEST_TRANSLATION_X_OFFSET,
+                    mLayout.getChildAt(i).getTranslationX(), .1f);
+        }
+
+        // Ensure that the Y translations were unaffected.
+        assertEquals(0, mLayout.getChildAt(0).getTranslationY(), .1f);
+        assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
+
+        // Animate the first child's Y translation.
+        mTestableController.animationForChildAtIndex(0)
+                .translationY(100f)
+                .start();
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_Y);
+
+        // Ensure that only the first view's Y translation chained, since we only chained X
+        // translations.
+        assertEquals(100, mLayout.getChildAt(0).getTranslationY(), .1f);
+        assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
+    }
+
+    @Test
+    @Ignore
+    public void testPhysicsAnimator() throws InterruptedException {
+        mLayout.setActiveController(mTestableController);
+        addOneMoreThanBubbleLimitBubbles();
+
+        Runnable afterAll = Mockito.mock(Runnable.class);
+        Runnable after = Mockito.spy(new Runnable() {
+            int mCallCount = 0;
+
+            @Override
+            public void run() {
+                // Make sure that if only one of the animations has finished, we didn't already call
+                // afterAll.
+                if (mCallCount == 1) {
+                    Mockito.verifyNoMoreInteractions(afterAll);
+                }
+            }
+        });
+
+        // Animate from x = 7 to x = 100, and from y = 100 to 7 = 200, calling 'after' after each
+        // property's animation completes, then call afterAll when they're all complete.
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(7, 100, after)
+                .translationY(100, 200, after)
+                .start(afterAll);
+
+        // We should have immediately set the 'from' values.
+        assertEquals(7, mViews.get(0).getTranslationX(), .01f);
+        assertEquals(100, mViews.get(0).getTranslationY(), .01f);
+
+        waitForPropertyAnimations(
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        // We should have called the after callback twice, and afterAll once. We verify in the
+        // mocked callback that afterAll isn't called before both finish.
+        Mockito.verify(after, times(2)).run();
+        Mockito.verify(afterAll).run();
+
+        // Make sure we actually animated the views.
+        assertEquals(100, mViews.get(0).getTranslationX(), .01f);
+        assertEquals(200, mViews.get(0).getTranslationY(), .01f);
+    }
+
+    @Test
+    @Ignore
+    public void testAnimationsForChildrenFromIndex() throws InterruptedException {
+        // Don't chain since we're going to invoke each animation independently.
+        mTestableController.setChainedProperties(new HashSet<>());
+
+        mLayout.setActiveController(mTestableController);
+
+        addOneMoreThanBubbleLimitBubbles();
+
+        Runnable allEnd = Mockito.mock(Runnable.class);
+
+        mTestableController.animationsForChildrenFromIndex(
+                1, (index, animation) -> animation.translationX((index - 1) * 50))
+            .startAll(allEnd);
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+        assertEquals(0, mViews.get(0).getTranslationX(), .1f);
+        assertEquals(0, mViews.get(1).getTranslationX(), .1f);
+        assertEquals(50, mViews.get(2).getTranslationX(), .1f);
+        assertEquals(100, mViews.get(3).getTranslationX(), .1f);
+
+        Mockito.verify(allEnd, times(1)).run();
+    }
+
+    @Test
+    @Ignore
+    public void testAnimationsForChildrenFromIndex_noChildren() {
+        mLayout.setActiveController(mTestableController);
+
+        final Runnable after = Mockito.mock(Runnable.class);
+        mTestableController
+                .animationsForChildrenFromIndex(0, (index, animation) -> { })
+                .startAll(after);
+
+        verify(after, Mockito.times(1)).run();
+    }
+
+    /**
+     * Animation controller with configuration methods whose return values can be set by individual
+     * tests.
+     */
+    private class TestableAnimationController
+            extends PhysicsAnimationLayout.PhysicsAnimationController {
+        private Set<DynamicAnimation.ViewProperty> mAnimatedProperties = new HashSet<>();
+        private Set<DynamicAnimation.ViewProperty> mChainedProperties = new HashSet<>();
+        private HashMap<DynamicAnimation.ViewProperty, Float> mOffsetForProperty = new HashMap<>();
+        private boolean mRemoveImmediately = false;
+
+        void setAnimatedProperties(
+                Set<DynamicAnimation.ViewProperty> animatedProperties) {
+            mAnimatedProperties = animatedProperties;
+        }
+
+        void setChainedProperties(
+                Set<DynamicAnimation.ViewProperty> chainedProperties) {
+            mChainedProperties = chainedProperties;
+        }
+
+        void setOffsetForProperty(
+                DynamicAnimation.ViewProperty property, float offset) {
+            mOffsetForProperty.put(property, offset);
+        }
+
+        public void setRemoveImmediately(boolean removeImmediately) {
+            mRemoveImmediately = removeImmediately;
+        }
+
+        @Override
+        Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+            return mAnimatedProperties;
+        }
+
+        @Override
+        int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+            return mChainedProperties.contains(property) ? index + 1 : NONE;
+        }
+
+        @Override
+        float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+            return mOffsetForProperty.getOrDefault(property, 0f);
+        }
+
+        @Override
+        SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+            return new SpringForce();
+        }
+
+        @Override
+        void onChildAdded(View child, int index) {}
+
+        @Override
+        void onChildRemoved(View child, int index, Runnable finishRemoval) {
+            if (mRemoveImmediately) {
+                finishRemoval.run();
+            }
+        }
+
+        @Override
+        void onChildReordered(View child, int oldIndex, int newIndex) {}
+
+        @Override
+        void onActiveControllerForLayout(PhysicsAnimationLayout layout) {}
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTestCase.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTestCase.java
new file mode 100644
index 0000000..a7a7db8
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/PhysicsAnimationLayoutTestCase.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.animation;
+
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.DisplayCutout;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowInsets;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.ShellTestCase;
+
+import org.junit.Before;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Test case for tests that involve the {@link PhysicsAnimationLayout}. This test case constructs a
+ * testable version of the layout, and provides some helpful methods to add views to the layout and
+ * wait for physics animations to finish running.
+ *
+ * See physics-animation-testing.md.
+ */
+public class PhysicsAnimationLayoutTestCase extends ShellTestCase {
+    TestablePhysicsAnimationLayout mLayout;
+    List<View> mViews = new ArrayList<>();
+
+    Handler mMainThreadHandler;
+
+    int mSystemWindowInsetSize = 50;
+    int mCutoutInsetSize = 100;
+
+    int mWidth = 1000;
+    int mHeight = 1000;
+
+    @Mock
+    private WindowInsets mWindowInsets;
+
+    @Mock
+    private DisplayCutout mCutout;
+
+    protected int mMaxBubbles;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        mLayout = new TestablePhysicsAnimationLayout(mContext);
+        mLayout.setLeft(0);
+        mLayout.setRight(mWidth);
+        mLayout.setTop(0);
+        mLayout.setBottom(mHeight);
+
+        mMaxBubbles =
+                getContext().getResources().getInteger(R.integer.bubbles_max_rendered);
+        mMainThreadHandler = new Handler(Looper.getMainLooper());
+
+        when(mWindowInsets.getSystemWindowInsetTop()).thenReturn(mSystemWindowInsetSize);
+        when(mWindowInsets.getSystemWindowInsetBottom()).thenReturn(mSystemWindowInsetSize);
+        when(mWindowInsets.getSystemWindowInsetLeft()).thenReturn(mSystemWindowInsetSize);
+        when(mWindowInsets.getSystemWindowInsetRight()).thenReturn(mSystemWindowInsetSize);
+
+        when(mWindowInsets.getDisplayCutout()).thenReturn(mCutout);
+        when(mCutout.getSafeInsetTop()).thenReturn(mCutoutInsetSize);
+        when(mCutout.getSafeInsetBottom()).thenReturn(mCutoutInsetSize);
+        when(mCutout.getSafeInsetLeft()).thenReturn(mCutoutInsetSize);
+        when(mCutout.getSafeInsetRight()).thenReturn(mCutoutInsetSize);
+    }
+
+    /** Add one extra bubble over the limit, so we can make sure it's gone/chains appropriately. */
+    void addOneMoreThanBubbleLimitBubbles() throws InterruptedException {
+        for (int i = 0; i < mMaxBubbles + 1; i++) {
+            final View newView = new FrameLayout(mContext);
+            mLayout.addView(newView, 0);
+            mViews.add(0, newView);
+
+            newView.setTranslationX(0);
+            newView.setTranslationY(0);
+        }
+    }
+
+    /**
+     * Uses a {@link java.util.concurrent.CountDownLatch} to wait for the given properties'
+     * animations to finish before allowing the test to proceed.
+     */
+    void waitForPropertyAnimations(DynamicAnimation.ViewProperty... properties)
+            throws InterruptedException {
+        final CountDownLatch animLatch = new CountDownLatch(properties.length);
+        for (DynamicAnimation.ViewProperty property : properties) {
+            mLayout.setTestEndActionForProperty(animLatch::countDown, property);
+        }
+
+        animLatch.await(2, TimeUnit.SECONDS);
+    }
+
+    /** Uses a latch to wait for the main thread message queue to finish. */
+    void waitForLayoutMessageQueue() throws InterruptedException {
+        CountDownLatch layoutLatch = new CountDownLatch(1);
+        mMainThreadHandler.post(layoutLatch::countDown);
+        layoutLatch.await(2, TimeUnit.SECONDS);
+    }
+
+    /**
+     * Testable subclass of the PhysicsAnimationLayout that ensures methods that trigger animations
+     * are run on the main thread, which is a requirement of DynamicAnimation.
+     */
+    protected class TestablePhysicsAnimationLayout extends PhysicsAnimationLayout {
+        public TestablePhysicsAnimationLayout(Context context) {
+            super(context);
+        }
+
+        @Override
+        protected boolean isActiveController(PhysicsAnimationController controller) {
+            // Return true since otherwise all test controllers will be seen as inactive since they
+            // are wrapped by MainThreadAnimationControllerWrapper.
+            return true;
+        }
+
+        @Override
+        public boolean post(Runnable action) {
+            return mMainThreadHandler.post(action);
+        }
+
+        @Override
+        public boolean postDelayed(Runnable action, long delayMillis) {
+            return mMainThreadHandler.postDelayed(action, delayMillis);
+        }
+
+        @Override
+        public void setActiveController(PhysicsAnimationController controller) {
+            runOnMainThreadAndBlock(
+                    () -> super.setActiveController(
+                            new MainThreadAnimationControllerWrapper(controller)));
+        }
+
+        @Override
+        public void cancelAllAnimations() {
+            mMainThreadHandler.post(super::cancelAllAnimations);
+        }
+
+        @Override
+        public void cancelAnimationsOnView(View view) {
+            mMainThreadHandler.post(() -> super.cancelAnimationsOnView(view));
+        }
+
+        @Override
+        public WindowInsets getRootWindowInsets() {
+            return mWindowInsets;
+        }
+
+        @Override
+        public void addView(View child, int index) {
+            child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
+            super.addView(child, index);
+        }
+
+        @Override
+        public void addView(View child, int index, ViewGroup.LayoutParams params) {
+            child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
+            super.addView(child, index, params);
+        }
+
+        /**
+         * Sets an end action that will be called after the 'real' end action that was already set.
+         */
+        private void setTestEndActionForProperty(
+                Runnable action, DynamicAnimation.ViewProperty property) {
+            final Runnable realEndAction = mEndActionForProperty.get(property);
+            mLayout.mEndActionForProperty.put(property, () -> {
+                if (realEndAction != null) {
+                    realEndAction.run();
+                }
+
+                action.run();
+            });
+        }
+
+        /** PhysicsPropertyAnimator that posts its animations to the main thread. */
+        protected class TestablePhysicsPropertyAnimator extends PhysicsPropertyAnimator {
+            public TestablePhysicsPropertyAnimator(View view) {
+                super(view);
+            }
+
+            @Override
+            protected void animateValueForChild(DynamicAnimation.ViewProperty property, View view,
+                    float value, float startVel, long startDelay, float stiffness,
+                    float dampingRatio, Runnable[] afterCallbacks) {
+                mMainThreadHandler.post(() -> super.animateValueForChild(
+                        property, view, value, startVel, startDelay, stiffness, dampingRatio,
+                        afterCallbacks));
+            }
+
+            @Override
+            protected void startPathAnimation() {
+                mMainThreadHandler.post(super::startPathAnimation);
+            }
+        }
+
+        /**
+         * Wrapper around an animation controller that dispatches methods that could start
+         * animations to the main thread.
+         */
+        protected class MainThreadAnimationControllerWrapper extends PhysicsAnimationController {
+
+            private final PhysicsAnimationController mWrappedController;
+
+            protected MainThreadAnimationControllerWrapper(PhysicsAnimationController controller) {
+                mWrappedController = controller;
+            }
+
+            @Override
+            protected void setLayout(PhysicsAnimationLayout layout) {
+                mWrappedController.setLayout(layout);
+            }
+
+            @Override
+            protected PhysicsAnimationLayout getLayout() {
+                return mWrappedController.getLayout();
+            }
+
+            @Override
+            Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+                return mWrappedController.getAnimatedProperties();
+            }
+
+            @Override
+            int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+                return mWrappedController.getNextAnimationInChain(property, index);
+            }
+
+            @Override
+            float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+                return mWrappedController.getOffsetForChainedPropertyAnimation(property);
+            }
+
+            @Override
+            SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+                return mWrappedController.getSpringForce(property, view);
+            }
+
+            @Override
+            void onChildAdded(View child, int index) {
+                runOnMainThreadAndBlock(() -> mWrappedController.onChildAdded(child, index));
+            }
+
+            @Override
+            void onChildRemoved(View child, int index, Runnable finishRemoval) {
+                runOnMainThreadAndBlock(
+                        () -> mWrappedController.onChildRemoved(child, index, finishRemoval));
+            }
+
+            @Override
+            void onChildReordered(View child, int oldIndex, int newIndex) {
+                runOnMainThreadAndBlock(
+                        () -> mWrappedController.onChildReordered(child, oldIndex, newIndex));
+            }
+
+            @Override
+            void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
+                runOnMainThreadAndBlock(
+                        () -> mWrappedController.onActiveControllerForLayout(layout));
+            }
+
+            @Override
+            protected PhysicsPropertyAnimator animationForChild(View child) {
+                PhysicsPropertyAnimator animator =
+                        (PhysicsPropertyAnimator) child.getTag(R.id.physics_animator_tag);
+
+                if (!(animator instanceof TestablePhysicsPropertyAnimator)) {
+                    animator = new TestablePhysicsPropertyAnimator(child);
+                    child.setTag(R.id.physics_animator_tag, animator);
+                }
+
+                return animator;
+            }
+        }
+    }
+
+    /**
+     * Posts the given Runnable on the main thread, and blocks the calling thread until it's run.
+     */
+    private void runOnMainThreadAndBlock(Runnable action) {
+        final CountDownLatch latch = new CountDownLatch(1);
+        mMainThreadHandler.post(() -> {
+            action.run();
+            latch.countDown();
+        });
+
+        try {
+            latch.await(5, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/StackAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/StackAnimationControllerTest.java
new file mode 100644
index 0000000..6b01462
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/StackAnimationControllerTest.java
@@ -0,0 +1,333 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.graphics.PointF;
+import android.testing.AndroidTestingRunner;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.bubbles.BubblePositioner;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.IntSupplier;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class StackAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
+
+    @Mock
+    private FloatingContentCoordinator mFloatingContentCoordinator;
+
+    private TestableStackController mStackController;
+
+    private int mStackOffset;
+    private Runnable mCheckStartPosSet;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        mStackController = spy(new TestableStackController(
+                mFloatingContentCoordinator, new IntSupplier() {
+                    @Override
+                    public int getAsInt() {
+                        return mLayout.getChildCount();
+                    }
+                }, mock(Runnable.class)));
+        mLayout.setActiveController(mStackController);
+        addOneMoreThanBubbleLimitBubbles();
+        mStackOffset = mLayout.getResources().getDimensionPixelSize(R.dimen.bubble_stack_offset);
+    }
+
+    /**
+     * Test moving around the stack, and make sure the position is updated correctly, and the stack
+     * direction is correct.
+     */
+    @Test
+    @Ignore("Flaking")
+    public void testMoveFirstBubbleWithStackFollowing() throws InterruptedException {
+        mStackController.moveFirstBubbleWithStackFollowing(200, 100);
+
+        // The first bubble should have moved instantly, the rest should be waiting for animation.
+        assertEquals(200, mViews.get(0).getTranslationX(), .1f);
+        assertEquals(100, mViews.get(0).getTranslationY(), .1f);
+        assertEquals(0, mViews.get(1).getTranslationX(), .1f);
+        assertEquals(0, mViews.get(1).getTranslationY(), .1f);
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        // Make sure the rest of the stack got moved to the right place and is stacked to the left.
+        testStackedAtPosition(200, 100, -1);
+        assertEquals(new PointF(200, 100), mStackController.getStackPosition());
+
+        mStackController.moveFirstBubbleWithStackFollowing(1000, 500);
+
+        // The first bubble again should have moved instantly while the rest remained where they
+        // were until the animation takes over.
+        assertEquals(1000, mViews.get(0).getTranslationX(), .1f);
+        assertEquals(500, mViews.get(0).getTranslationY(), .1f);
+        assertEquals(200 + -mStackOffset, mViews.get(1).getTranslationX(), .1f);
+        assertEquals(100, mViews.get(1).getTranslationY(), .1f);
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        // Make sure the rest of the stack moved again, including the first bubble not moving, and
+        // is stacked to the right now that we're on the right side of the screen.
+        testStackedAtPosition(1000, 500, 1);
+        assertEquals(new PointF(1000, 500), mStackController.getStackPosition());
+    }
+
+    @Test
+    @Ignore("Sporadically failing due to DynamicAnimation not settling.")
+    public void testFlingSideways() throws InterruptedException {
+        // Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
+        // immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
+        // but should bounce back down.
+        mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_X,
+                5000f, 1.15f, new SpringForce(), mWidth * 1f);
+        mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_Y,
+                0f, 1.15f, new SpringForce(), 0f);
+
+        // Nothing should move initially since the animations haven't begun, including the first
+        // view.
+        assertEquals(0f, mViews.get(0).getTranslationX(), 1f);
+        assertEquals(0f, mViews.get(0).getTranslationY(), 1f);
+
+        // Wait for the flinging.
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        // Wait for the springing.
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        // Once the dust has settled, we should have flung all the way to the right side, with the
+        // stack stacked off to the right now.
+        testStackedAtPosition(mWidth * 1f, 0f, 1);
+    }
+
+    @Test
+    @Ignore("Sporadically failing due to DynamicAnimation not settling.")
+    public void testFlingUpFromBelowBottomCenter() throws InterruptedException {
+        // Move to the center of the screen, just past the bottom.
+        mStackController.moveFirstBubbleWithStackFollowing(mWidth / 2f, mHeight + 100);
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        // Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
+        // immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
+        // but should bounce back down.
+        mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_X,
+                0, 1.15f, new SpringForce(), 27f);
+        mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.TRANSLATION_Y,
+                5000f, 1.15f, new SpringForce(), 27f);
+
+        // Nothing should move initially since the animations haven't begun.
+        assertEquals(mWidth / 2f, mViews.get(0).getTranslationX(), .1f);
+        assertEquals(mHeight + 100, mViews.get(0).getTranslationY(), .1f);
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        // Once the dust has settled, we should have flung a bit but then sprung to the final
+        // destination which is (27, 27).
+        testStackedAtPosition(27, 27, -1);
+    }
+
+    @Test
+    @Ignore("Flaking")
+    public void testChildAdded() throws InterruptedException {
+        // Move the stack to y = 500.
+        mStackController.moveFirstBubbleWithStackFollowing(0f, 500f);
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        final View newView = new FrameLayout(mContext);
+        mLayout.addView(
+                newView,
+                0,
+                new FrameLayout.LayoutParams(50, 50));
+
+        waitForStartPosToBeSet();
+        waitForLayoutMessageQueue();
+        waitForPropertyAnimations(
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y,
+                DynamicAnimation.SCALE_X,
+                DynamicAnimation.SCALE_Y);
+
+        // The new view should be at the top of the stack, in the correct position.
+        assertEquals(0f, newView.getTranslationX(), .1f);
+        assertEquals(500f, newView.getTranslationY(), .1f);
+        assertEquals(1f, newView.getScaleX(), .1f);
+        assertEquals(1f, newView.getScaleY(), .1f);
+        assertEquals(1f, newView.getAlpha(), .1f);
+    }
+
+    @Test
+    @Ignore("Occasionally flakes, ignoring pending investigation.")
+    public void testChildRemoved() throws InterruptedException {
+        assertEquals(0, mLayout.getTransientViewCount());
+
+        final View firstView = mLayout.getChildAt(0);
+        mLayout.removeView(firstView);
+
+        // The view should now be transient, and missing from the view's normal hierarchy.
+        assertEquals(1, mLayout.getTransientViewCount());
+        assertEquals(-1, mLayout.indexOfChild(firstView));
+
+        waitForPropertyAnimations(DynamicAnimation.ALPHA);
+        waitForLayoutMessageQueue();
+
+        // The view should now be gone entirely, no transient views left.
+        assertEquals(0, mLayout.getTransientViewCount());
+
+        // The subsequent view should have been translated over to 0, not stacked off to the left.
+        assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+    }
+
+    @Test
+    @Ignore("Flaky")
+    public void testRestoredAtRestingPosition() throws InterruptedException {
+        mStackController.flingStackThenSpringToEdge(0, 5000, 5000);
+
+        waitForPropertyAnimations(
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+        waitForLayoutMessageQueue();
+
+        final PointF prevStackPos = mStackController.getStackPosition();
+
+        mLayout.removeAllViews();
+
+        waitForLayoutMessageQueue();
+
+        mLayout.addView(new FrameLayout(getContext()));
+
+        waitForLayoutMessageQueue();
+        waitForPropertyAnimations(
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+        assertEquals(prevStackPos, mStackController.getStackPosition());
+    }
+
+    @Test
+    public void testFloatingCoordinator() {
+        // We should have called onContentAdded only once while adding all of the bubbles in
+        // setup().
+        verify(mFloatingContentCoordinator, times(1)).onContentAdded(any());
+        verify(mFloatingContentCoordinator, never()).onContentRemoved(any());
+
+        // Remove all views and verify that we called onContentRemoved only once.
+        while (mLayout.getChildCount() > 0) {
+            mLayout.removeView(mLayout.getChildAt(0));
+        }
+
+        verify(mFloatingContentCoordinator, times(1)).onContentRemoved(any());
+    }
+
+    /**
+     * Checks every child view to make sure it's stacked at the given coordinates, off to the left
+     * or right side depending on offset multiplier.
+     */
+    private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
+        // Make sure the rest of the stack moved again, including the first bubble not moving, and
+        // is stacked to the right now that we're on the right side of the screen.
+        for (int i = 0; i < mLayout.getChildCount(); i++) {
+            assertEquals(x + i * offsetMultiplier * mStackOffset,
+                    mViews.get(i).getTranslationX(), 2f);
+            assertEquals(y, mViews.get(i).getTranslationY(), 2f);
+        }
+    }
+
+    /** Waits up to 2 seconds for the initial stack position to be initialized. */
+    private void waitForStartPosToBeSet() throws InterruptedException {
+        final CountDownLatch animLatch = new CountDownLatch(1);
+
+        mCheckStartPosSet = () -> {
+            if (mStackController.getStackPosition().x >= 0) {
+                animLatch.countDown();
+            } else {
+                mMainThreadHandler.post(mCheckStartPosSet);
+            }
+        };
+
+        mMainThreadHandler.post(mCheckStartPosSet);
+
+        try {
+            animLatch.await(2, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            mMainThreadHandler.removeCallbacks(mCheckStartPosSet);
+            throw e;
+        }
+    }
+
+    /**
+     * Testable version of the stack controller that dispatches its animations on the main thread.
+     */
+    private class TestableStackController extends StackAnimationController {
+        TestableStackController(
+                FloatingContentCoordinator floatingContentCoordinator,
+                IntSupplier bubbleCountSupplier,
+                Runnable onBubbleAnimatedOutAction) {
+            super(floatingContentCoordinator,
+                    bubbleCountSupplier,
+                    onBubbleAnimatedOutAction,
+                    mock(BubblePositioner.class));
+        }
+
+        @Override
+        protected void flingThenSpringFirstBubbleWithStackFollowing(
+                DynamicAnimation.ViewProperty property, float vel, float friction,
+                SpringForce spring, Float finalPosition) {
+            mMainThreadHandler.post(() ->
+                    super.flingThenSpringFirstBubbleWithStackFollowing(
+                            property, vel, friction, spring, finalPosition));
+        }
+
+        @Override
+        protected void springFirstBubbleWithStackFollowing(DynamicAnimation.ViewProperty property,
+                SpringForce spring, float vel, float finalPosition, Runnable... after) {
+            mMainThreadHandler.post(() ->
+                    super.springFirstBubbleWithStackFollowing(
+                            property, spring, vel, finalPosition, after));
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepositoryTest.kt
new file mode 100644
index 0000000..4160280
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubblePersistentRepositoryTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.ShellTestCase
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertNotNull
+import junit.framework.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class BubblePersistentRepositoryTest : ShellTestCase() {
+
+    private val bubbles = listOf(
+            BubbleEntity(0, "com.example.messenger", "shortcut-1", "key-1", 120, 0),
+            BubbleEntity(10, "com.example.chat", "alice and bob", "key-2", 0, 16537428, "title"),
+            BubbleEntity(0, "com.example.messenger", "shortcut-2", "key-3", 120, 0)
+    )
+    private lateinit var repository: BubblePersistentRepository
+
+    @Before
+    fun setup() {
+        repository = BubblePersistentRepository(mContext)
+    }
+
+    @Test
+    fun testReadWriteOperation() {
+        // Verify read before write doesn't cause FileNotFoundException
+        val actual = repository.readFromDisk()
+        assertNotNull(actual)
+        assertTrue(actual.isEmpty())
+
+        repository.persistsToDisk(bubbles)
+        assertEquals(bubbles, repository.readFromDisk())
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepositoryTest.kt
new file mode 100644
index 0000000..4fab9a5
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleVolatileRepositoryTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.content.pm.LauncherApps
+import android.os.UserHandle
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.util.mockito.eq
+import com.android.wm.shell.ShellTestCase
+import junit.framework.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class BubbleVolatileRepositoryTest : ShellTestCase() {
+
+    private val user0 = UserHandle.of(0)
+    private val user10 = UserHandle.of(10)
+
+    private val bubble1 = BubbleEntity(0, "com.example.messenger", "shortcut-1", "key-1", 120, 0)
+    private val bubble2 = BubbleEntity(10, "com.example.chat", "alice and bob",
+            "key-2", 0, 16537428, "title")
+    private val bubble3 = BubbleEntity(0, "com.example.messenger", "shortcut-2", "key-3", 120, 0)
+
+    private val bubbles = listOf(bubble1, bubble2, bubble3)
+
+    private lateinit var repository: BubbleVolatileRepository
+    private lateinit var launcherApps: LauncherApps
+
+    @Before
+    fun setup() {
+        launcherApps = mock(LauncherApps::class.java)
+        repository = BubbleVolatileRepository(launcherApps)
+    }
+
+    @Test
+    fun testAddBubbles() {
+        repository.addBubbles(bubbles)
+        assertEquals(bubbles, repository.bubbles)
+        verify(launcherApps).cacheShortcuts(eq(PKG_MESSENGER),
+                eq(listOf("shortcut-1", "shortcut-2")), eq(user0),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+        verify(launcherApps).cacheShortcuts(eq(PKG_CHAT),
+                eq(listOf("alice and bob")), eq(user10),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+
+        repository.addBubbles(listOf(bubble1))
+        assertEquals(listOf(bubble2, bubble3, bubble1), repository.bubbles)
+        verifyNoMoreInteractions(launcherApps)
+    }
+
+    @Test
+    fun testRemoveBubbles() {
+        repository.addBubbles(bubbles)
+        assertEquals(bubbles, repository.bubbles)
+
+        repository.removeBubbles(listOf(bubble3))
+        assertEquals(listOf(bubble1, bubble2), repository.bubbles)
+        verify(launcherApps).uncacheShortcuts(eq(PKG_MESSENGER),
+                eq(listOf("shortcut-2")), eq(user0),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+    }
+
+    @Test
+    fun testAddAndRemoveBubblesWhenExceedingCapacity() {
+        repository.capacity = 2
+        // push bubbles beyond capacity
+        repository.addBubbles(bubbles)
+        // verify it is trim down to capacity
+        assertEquals(listOf(bubble2, bubble3), repository.bubbles)
+        verify(launcherApps).cacheShortcuts(eq(PKG_MESSENGER),
+                eq(listOf("shortcut-2")), eq(user0),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+        verify(launcherApps).cacheShortcuts(eq(PKG_CHAT),
+                eq(listOf("alice and bob")), eq(user10),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+
+        repository.addBubbles(listOf(bubble1))
+        // verify the oldest bubble is popped
+        assertEquals(listOf(bubble3, bubble1), repository.bubbles)
+        verify(launcherApps).uncacheShortcuts(eq(PKG_CHAT),
+                eq(listOf("alice and bob")), eq(user10),
+                eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
+    }
+
+    @Test
+    fun testAddBubbleMatchesByKey() {
+        val bubble = BubbleEntity(0, "com.example.pkg", "shortcut-id", "key", 120, 0, "title")
+        repository.addBubbles(listOf(bubble))
+        assertEquals(bubble, repository.bubbles.get(0))
+
+        // Same key as first bubble but different entry
+        val bubbleModified = BubbleEntity(0, "com.example.pkg", "shortcut-id", "key", 120, 0,
+                "different title")
+        repository.addBubbles(listOf(bubbleModified))
+        assertEquals(bubbleModified, repository.bubbles.get(0))
+    }
+}
+
+private const val PKG_MESSENGER = "com.example.messenger"
+private const val PKG_CHAT = "com.example.chat"
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelperTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelperTest.kt
new file mode 100644
index 0000000..e0891a9
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/storage/BubbleXmlHelperTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.bubbles.storage
+
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.ShellTestCase
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class BubbleXmlHelperTest : ShellTestCase() {
+
+    private val bubbles = listOf(
+            BubbleEntity(0, "com.example.messenger", "shortcut-1", "k1", 120, 0),
+            BubbleEntity(10, "com.example.chat", "alice and bob", "k2", 0, 16537428, "title"),
+            BubbleEntity(0, "com.example.messenger", "shortcut-2", "k3", 120, 0)
+    )
+
+    @Test
+    fun testWriteXml() {
+        val expectedEntries = """
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
+<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
+        """.trimIndent()
+        ByteArrayOutputStream().use {
+            writeXml(it, bubbles)
+            val actual = it.toString()
+            assertTrue("cannot find expected entry in \n$actual",
+                    actual.contains(expectedEntries))
+        }
+    }
+
+    @Test
+    fun testReadXml() {
+        val src = """
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<bs v="1">
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
+<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
+</bs>
+        """.trimIndent()
+        val actual = readXml(ByteArrayInputStream(src.toByteArray(Charsets.UTF_8)))
+        assertEquals("failed parsing bubbles from xml\n$src", bubbles, actual)
+    }
+
+    // TODO: We should handle upgrades gracefully but this is v1
+    @Test
+    fun testUpgradeDropsPreviousData() {
+        val src = """
+<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
+<bs>
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
+<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
+<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
+</bs>
+        """.trimIndent()
+        val actual = readXml(ByteArrayInputStream(src.toByteArray(Charsets.UTF_8)))
+        assertEquals("failed parsing bubbles from xml\n$src", emptyList<BubbleEntity>(), actual)
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java
index 255e749..55e7a35 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java
@@ -32,9 +32,7 @@
 
 import androidx.test.filters.SmallTest;
 
-import com.android.wm.shell.pip.PipAnimationController;
-import com.android.wm.shell.pip.PipSurfaceTransactionHelper;
-import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.ShellTestCase;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -49,7 +47,7 @@
 @RunWith(AndroidTestingRunner.class)
 @SmallTest
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
-public class PipAnimationControllerTest extends PipTestCase {
+public class PipAnimationControllerTest extends ShellTestCase {
 
     private PipAnimationController mPipAnimationController;
 
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java
index 37421d9..5169243 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java
@@ -30,8 +30,7 @@
 
 import androidx.test.filters.SmallTest;
 
-import com.android.wm.shell.pip.PipBoundsHandler;
-import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.ShellTestCase;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -46,7 +45,7 @@
 @RunWith(AndroidTestingRunner.class)
 @SmallTest
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
-public class PipBoundsHandlerTest extends PipTestCase {
+public class PipBoundsHandlerTest extends ShellTestCase {
     private static final int ROUNDING_ERROR_MARGIN = 16;
     private static final float ASPECT_RATIO_ERROR_MARGIN = 0.01f;
     private static final float DEFAULT_ASPECT_RATIO = 1f;
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsStateTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsStateTest.java
index dc9399e..844f82d 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsStateTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsStateTest.java
@@ -28,6 +28,8 @@
 
 import androidx.test.filters.SmallTest;
 
+import com.android.wm.shell.ShellTestCase;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -38,7 +40,7 @@
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
 @SmallTest
-public class PipBoundsStateTest extends PipTestCase {
+public class PipBoundsStateTest extends ShellTestCase {
 
     private static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 10, 10);
     private static final float DEFAULT_SNAP_FRACTION = 1.0f;
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTaskOrganizerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTaskOrganizerTest.java
index 39381c6..efe553a 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTaskOrganizerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTaskOrganizerTest.java
@@ -40,6 +40,7 @@
 import android.window.WindowContainerToken;
 
 import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.ShellTestCase;
 import com.android.wm.shell.common.DisplayController;
 import com.android.wm.shell.pip.phone.PipMenuActivityController;
 import com.android.wm.shell.splitscreen.SplitScreen;
@@ -58,7 +59,7 @@
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
-public class PipTaskOrganizerTest extends PipTestCase {
+public class PipTaskOrganizerTest extends ShellTestCase {
     private PipTaskOrganizer mSpiedPipTaskOrganizer;
 
     @Mock private DisplayController mMockdDisplayController;
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java
index 5f0f196..a00a3b6 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java
@@ -35,6 +35,7 @@
 import android.testing.AndroidTestingRunner;
 import android.testing.TestableLooper;
 
+import com.android.wm.shell.ShellTestCase;
 import com.android.wm.shell.WindowManagerShellWrapper;
 import com.android.wm.shell.common.DisplayController;
 import com.android.wm.shell.common.ShellExecutor;
@@ -42,7 +43,6 @@
 import com.android.wm.shell.pip.PipBoundsState;
 import com.android.wm.shell.pip.PipMediaController;
 import com.android.wm.shell.pip.PipTaskOrganizer;
-import com.android.wm.shell.pip.PipTestCase;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -56,7 +56,7 @@
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
-public class PipControllerTest extends PipTestCase {
+public class PipControllerTest extends ShellTestCase {
     private PipController mPipController;
 
     @Mock private DisplayController mMockDisplayController;
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java
index 3f60cc0..f6dcec2 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java
@@ -31,17 +31,13 @@
 import androidx.test.filters.SmallTest;
 
 import com.android.wm.shell.R;
+import com.android.wm.shell.ShellTestCase;
 import com.android.wm.shell.common.FloatingContentCoordinator;
 import com.android.wm.shell.pip.PipBoundsHandler;
 import com.android.wm.shell.pip.PipBoundsState;
 import com.android.wm.shell.pip.PipSnapAlgorithm;
 import com.android.wm.shell.pip.PipTaskOrganizer;
-import com.android.wm.shell.pip.PipTestCase;
 import com.android.wm.shell.pip.PipUiEventLogger;
-import com.android.wm.shell.pip.phone.PipMenuActivityController;
-import com.android.wm.shell.pip.phone.PipMotionHelper;
-import com.android.wm.shell.pip.phone.PipResizeGestureHandler;
-import com.android.wm.shell.pip.phone.PipTouchHandler;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -59,7 +55,7 @@
 @RunWith(AndroidTestingRunner.class)
 @SmallTest
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
-public class PipTouchHandlerTest extends PipTestCase {
+public class PipTouchHandlerTest extends ShellTestCase {
 
     private PipTouchHandler mPipTouchHandler;
 
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java
index 40667f7..000f7e8 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java
@@ -35,8 +35,7 @@
 
 import androidx.test.filters.SmallTest;
 
-import com.android.wm.shell.pip.PipTestCase;
-import com.android.wm.shell.pip.phone.PipTouchState;
+import com.android.wm.shell.ShellTestCase;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -47,7 +46,7 @@
 @RunWith(AndroidTestingRunner.class)
 @SmallTest
 @RunWithLooper
-public class PipTouchStateTest extends PipTestCase {
+public class PipTouchStateTest extends ShellTestCase {
 
     private PipTouchState mTouchState;
     private CountDownLatch mDoubleTapCallbackTriggeredLatch;