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)
+    }
+}