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;