Create BubbleBarFlyoutView
Initial version of the flyout view for displaying bubble bar
notification.
Flag: com.android.wm.shell.enable_bubble_bar
Bug: 277815200
Test: atest BubbleBarFlyoutViewScreenshotTest
Test: atest BubbleBarFlyoutControllerTest
Change-Id: I5d0643fe5d2691ad2349b45eaaad6cd2660d9df0
diff --git a/quickstep/res/layout/bubblebar_flyout.xml b/quickstep/res/layout/bubblebar_flyout.xml
new file mode 100644
index 0000000..ff5047f
--- /dev/null
+++ b/quickstep/res/layout/bubblebar_flyout.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2024 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"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <ImageView
+ android:id="@+id/bubble_flyout_avatar"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:padding="@dimen/bubblebar_flyout_avatar_message_space"
+ android:scaleType="centerInside"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ tools:src="#ff0000"/>
+
+ <TextView
+ android:id="@+id/bubble_flyout_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:fontFamily="@*android:string/config_bodyFontFamilyMedium"
+ android:maxLines="1"
+ android:ellipsize="end"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintStart_toEndOf="@id/bubble_flyout_avatar"
+ tools:text="Sender"/>
+
+ <TextView
+ android:id="@+id/bubble_flyout_text"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:fontFamily="@*android:string/config_bodyFontFamily"
+ android:maxLines="2"
+ android:ellipsize="end"
+ app:layout_constraintTop_toBottomOf="@id/bubble_flyout_name"
+ app:layout_constraintStart_toEndOf="@id/bubble_flyout_avatar"
+ tools:text="This is a message"/>
+
+</merge>
diff --git a/quickstep/res/values/dimens.xml b/quickstep/res/values/dimens.xml
index ce3f3ac..feb135b 100644
--- a/quickstep/res/values/dimens.xml
+++ b/quickstep/res/values/dimens.xml
@@ -480,6 +480,13 @@
<dimen name="bubble_expanded_view_drop_target_padding">24dp</dimen>
<dimen name="bubble_expanded_view_drop_target_margin">16dp</dimen>
+ <!-- Bubble bar flyout view -->
+ <dimen name="bubblebar_flyout_padding_horizontal">14dp</dimen>
+ <dimen name="bubblebar_flyout_padding_vertical">10dp</dimen>
+ <dimen name="bubblebar_flyout_elevation">4dp</dimen>
+ <dimen name="bubblebar_flyout_avatar_message_space">6dp</dimen>
+ <dimen name="bubblebar_flyout_max_width">96dp</dimen>
+
<!-- Launcher splash screen -->
<!-- Note: keep this value in sync with the WindowManager/Shell dimens.xml -->
<!-- starting_surface_exit_animation_window_shift_length -->
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt
new file mode 100644
index 0000000..152dcf7
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import com.android.launcher3.R
+
+/** Creates and manages the visibility of the [BubbleBarFlyoutView]. */
+class BubbleBarFlyoutController(
+ private val container: FrameLayout,
+ private val positioner: BubbleBarFlyoutPositioner,
+) {
+
+ private var flyout: BubbleBarFlyoutView? = null
+ val horizontalMargin =
+ container.context.resources.getDimensionPixelSize(R.dimen.transient_taskbar_bottom_margin)
+
+ fun setUpFlyout(message: BubbleBarFlyoutMessage) {
+ flyout?.let(container::removeView)
+ val flyout = BubbleBarFlyoutView(container.context)
+
+ flyout.translationY = positioner.targetTy
+
+ val lp =
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ Gravity.BOTTOM or if (positioner.isOnLeft) Gravity.LEFT else Gravity.RIGHT,
+ )
+ lp.marginStart = horizontalMargin
+ lp.marginEnd = horizontalMargin
+ container.addView(flyout, lp)
+
+ flyout.setData(message)
+ this.flyout = flyout
+ }
+
+ fun hideFlyout() {
+ val flyout = this.flyout ?: return
+ container.removeView(flyout)
+ this.flyout = null
+ }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutMessage.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutMessage.kt
new file mode 100644
index 0000000..7298297
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutMessage.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+import android.graphics.drawable.Drawable
+
+data class BubbleBarFlyoutMessage(
+ val senderAvatar: Drawable?,
+ val senderName: CharSequence,
+ val message: CharSequence,
+ val isGroupChat: Boolean,
+)
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutPositioner.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutPositioner.kt
new file mode 100644
index 0000000..deed1f5
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutPositioner.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+/** Provides positioning data to the flyout view. */
+interface BubbleBarFlyoutPositioner {
+
+ /** Whether the flyout view should be positioned on left or the right edge. */
+ val isOnLeft: Boolean
+
+ /** The target translation Y that the flyout view should have when displayed. */
+ val targetTy: Float
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutView.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutView.kt
new file mode 100644
index 0000000..d3dc3f8
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutView.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.view.LayoutInflater
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import com.android.launcher3.R
+
+/** The flyout view used to notify the user of a new bubble notification. */
+class BubbleBarFlyoutView(context: Context) : ConstraintLayout(context) {
+
+ private val sender: TextView by
+ lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_name) }
+
+ private val avatar: ImageView by
+ lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_avatar) }
+
+ private val message: TextView by
+ lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_text) }
+
+ private val flyoutHorizontalPadding by
+ lazy(LazyThreadSafetyMode.NONE) {
+ context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_padding_horizontal)
+ }
+
+ private val maxFlyoutWidth by
+ lazy(LazyThreadSafetyMode.NONE) {
+ context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_max_width)
+ }
+
+ private val cornerRadius: Float
+ private var backgroundColor = Color.BLACK
+
+ /**
+ * The paint used to draw the background, whose color changes as the flyout transitions to the
+ * tinted notification dot.
+ */
+ private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.bubblebar_flyout, this, true)
+
+ val ta = context.obtainStyledAttributes(intArrayOf(android.R.attr.dialogCornerRadius))
+ cornerRadius = ta.getDimensionPixelSize(0, 0).toFloat()
+ ta.recycle()
+
+ setWillNotDraw(false)
+ clipChildren = false
+ clipToPadding = false
+
+ val horizontalPadding =
+ context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_padding_horizontal)
+ val verticalPadding =
+ context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_padding_vertical)
+ setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
+ translationZ =
+ context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_elevation).toFloat()
+ applyConfigurationColors(resources.configuration)
+ }
+
+ fun setData(flyoutMessage: BubbleBarFlyoutMessage) {
+ // the avatar is only displayed in group chat messages
+ if (flyoutMessage.senderAvatar != null && flyoutMessage.isGroupChat) {
+ avatar.visibility = VISIBLE
+ avatar.setImageDrawable(flyoutMessage.senderAvatar)
+ } else {
+ avatar.visibility = GONE
+ }
+
+ val maxTextViewWidth = maxFlyoutWidth - flyoutHorizontalPadding * 2
+ if (flyoutMessage.senderName.isEmpty()) {
+ sender.visibility = GONE
+ } else {
+ sender.maxWidth = maxTextViewWidth
+ sender.text = flyoutMessage.senderName
+ sender.visibility = VISIBLE
+ }
+
+ message.maxWidth = maxTextViewWidth
+ message.text = flyoutMessage.message
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ canvas.drawRoundRect(
+ 0f,
+ 0f,
+ width.toFloat(),
+ height.toFloat(),
+ cornerRadius,
+ cornerRadius,
+ backgroundPaint,
+ )
+ super.onDraw(canvas)
+ }
+
+ private fun applyConfigurationColors(configuration: Configuration) {
+ val nightModeFlags = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+ val isNightModeOn = nightModeFlags == Configuration.UI_MODE_NIGHT_YES
+ val defaultBackgroundColor = if (isNightModeOn) Color.BLACK else Color.WHITE
+ val defaultTextColor = if (isNightModeOn) Color.WHITE else Color.BLACK
+ val ta =
+ context.obtainStyledAttributes(
+ intArrayOf(
+ com.android.internal.R.attr.materialColorSurfaceContainer,
+ com.android.internal.R.attr.materialColorOnSurface,
+ com.android.internal.R.attr.materialColorOnSurfaceVariant,
+ )
+ )
+ backgroundColor = ta.getColor(0, defaultBackgroundColor)
+ sender.setTextColor(ta.getColor(1, defaultTextColor))
+ message.setTextColor(ta.getColor(2, defaultTextColor))
+ ta.recycle()
+ backgroundPaint.color = backgroundColor
+ }
+}
diff --git a/quickstep/tests/multivalentScreenshotTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutViewScreenshotTest.kt b/quickstep/tests/multivalentScreenshotTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutViewScreenshotTest.kt
new file mode 100644
index 0000000..b1ff4a1
--- /dev/null
+++ b/quickstep/tests/multivalentScreenshotTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutViewScreenshotTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.apps.nexuslauncher.imagecomparison.goldenpathmanager.ViewScreenshotGoldenPathManager
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4
+import platform.test.runner.parameterized.Parameters
+import platform.test.screenshot.DeviceEmulationSpec
+import platform.test.screenshot.Displays
+import platform.test.screenshot.ViewScreenshotTestRule
+import platform.test.screenshot.getEmulatedDevicePathConfig
+
+/** Screenshot tests for [BubbleBarFlyoutView]. */
+@RunWith(ParameterizedAndroidJunit4::class)
+class BubbleBarFlyoutViewScreenshotTest(emulationSpec: DeviceEmulationSpec) {
+
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+
+ companion object {
+ @Parameters(name = "{0}")
+ @JvmStatic
+ fun getTestSpecs() =
+ DeviceEmulationSpec.forDisplays(
+ Displays.Phone,
+ isDarkTheme = false,
+ isLandscape = false,
+ )
+ }
+
+ @get:Rule
+ val screenshotRule =
+ ViewScreenshotTestRule(
+ emulationSpec,
+ ViewScreenshotGoldenPathManager(getEmulatedDevicePathConfig(emulationSpec)),
+ )
+
+ @Test
+ fun bubbleBarFlyoutView_noAvatar() {
+ screenshotRule.screenshotTest("bubbleBarFlyoutView_noAvatar") { activity ->
+ activity.actionBar?.hide()
+ val flyout = BubbleBarFlyoutView(context)
+ flyout.setData(
+ BubbleBarFlyoutMessage(
+ senderAvatar = null,
+ senderName = "sender",
+ message = "message",
+ isGroupChat = false,
+ )
+ )
+ flyout
+ }
+ }
+
+ @Test
+ fun bubbleBarFlyoutView_avatar() {
+ screenshotRule.screenshotTest("bubbleBarFlyoutView_avatar") { activity ->
+ activity.actionBar?.hide()
+ val flyout = BubbleBarFlyoutView(context)
+ flyout.setData(
+ BubbleBarFlyoutMessage(
+ senderAvatar = ColorDrawable(Color.RED),
+ senderName = "sender",
+ message = "message",
+ isGroupChat = true,
+ )
+ )
+ flyout
+ }
+ }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt
new file mode 100644
index 0000000..a58ce08
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2024 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.launcher3.taskbar.bubbles.flyout
+
+import android.content.Context
+import android.view.Gravity
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.R
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Unit tests for [BubbleBarFlyoutController] */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BubbleBarFlyoutControllerTest {
+
+ private lateinit var flyoutController: BubbleBarFlyoutController
+ private lateinit var flyoutContainer: FrameLayout
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+ private val flyoutMessage =
+ BubbleBarFlyoutMessage(senderAvatar = null, "sender name", "message", isGroupChat = false)
+ private var onLeft = true
+
+ @Before
+ fun setUp() {
+ flyoutContainer = FrameLayout(context)
+ val positioner =
+ object : BubbleBarFlyoutPositioner {
+ override val isOnLeft: Boolean
+ get() = onLeft
+
+ override val targetTy: Float
+ get() = 50f
+ }
+ flyoutController = BubbleBarFlyoutController(flyoutContainer, positioner)
+ }
+
+ @Test
+ fun flyoutPosition_left() {
+ flyoutController.setUpFlyout(flyoutMessage)
+ assertThat(flyoutContainer.childCount).isEqualTo(1)
+ val flyout = flyoutContainer.getChildAt(0)
+ val lp = flyout.layoutParams as FrameLayout.LayoutParams
+ assertThat(lp.gravity).isEqualTo(Gravity.BOTTOM or Gravity.LEFT)
+ assertThat(flyout.translationY).isEqualTo(50f)
+ }
+
+ @Test
+ fun flyoutPosition_right() {
+ onLeft = false
+ flyoutController.setUpFlyout(flyoutMessage)
+ assertThat(flyoutContainer.childCount).isEqualTo(1)
+ val flyout = flyoutContainer.getChildAt(0)
+ val lp = flyout.layoutParams as FrameLayout.LayoutParams
+ assertThat(lp.gravity).isEqualTo(Gravity.BOTTOM or Gravity.RIGHT)
+ assertThat(flyout.translationY).isEqualTo(50f)
+ }
+
+ @Test
+ fun flyoutMessage() {
+ flyoutController.setUpFlyout(flyoutMessage)
+ assertThat(flyoutContainer.childCount).isEqualTo(1)
+ val flyout = flyoutContainer.getChildAt(0)
+ val sender = flyout.findViewById<TextView>(R.id.bubble_flyout_name)
+ assertThat(sender.text).isEqualTo("sender name")
+ val message = flyout.findViewById<TextView>(R.id.bubble_flyout_text)
+ assertThat(message.text).isEqualTo("message")
+ }
+
+ @Test
+ fun hideFlyout_removedFromContainer() {
+ flyoutController.setUpFlyout(flyoutMessage)
+ assertThat(flyoutContainer.childCount).isEqualTo(1)
+ flyoutController.hideFlyout()
+ assertThat(flyoutContainer.childCount).isEqualTo(0)
+ }
+}