feat: WM-Shell QPR1 (not src or shared)

This commit is contained in:
Pun Butrach
2025-11-18 18:38:40 +07:00
parent 383d7737f7
commit cbbafce397
281 changed files with 28915 additions and 9862 deletions
@@ -1,670 +0,0 @@
/*
* Copyright (C) 2023 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.Intent
import android.content.pm.ShortcutInfo
import android.content.res.Resources
import android.graphics.Insets
import android.graphics.PointF
import android.graphics.Rect
import android.os.UserHandle
import android.view.WindowManager
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.protolog.common.ProtoLog
import com.android.wm.shell.R
import com.android.wm.shell.bubbles.BubblePositioner.MAX_HEIGHT
import com.android.wm.shell.common.bubbles.BubbleBarLocation
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.MoreExecutors.directExecutor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Tests operations and the resulting state managed by [BubblePositioner]. */
@SmallTest
@RunWith(AndroidJUnit4::class)
class BubblePositionerTest {
private lateinit var positioner: BubblePositioner
private val context = ApplicationProvider.getApplicationContext<Context>()
private val resources: Resources
get() = context.resources
private val defaultDeviceConfig =
DeviceConfig(
windowBounds = Rect(0, 0, 1000, 2000),
isLargeScreen = false,
isSmallTablet = false,
isLandscape = false,
isRtl = false,
insets = Insets.of(0, 0, 0, 0)
)
@Before
fun setUp() {
ProtoLog.REQUIRE_PROTOLOGTOOL = false
val windowManager = context.getSystemService(WindowManager::class.java)
positioner = BubblePositioner(context, windowManager)
}
@Test
fun testUpdate() {
val insets = Insets.of(10, 20, 5, 15)
val screenBounds = Rect(0, 0, 1000, 1200)
val availableRect = Rect(screenBounds)
availableRect.inset(insets)
positioner.update(defaultDeviceConfig.copy(insets = insets, windowBounds = screenBounds))
assertThat(positioner.availableRect).isEqualTo(availableRect)
assertThat(positioner.isLandscape).isFalse()
assertThat(positioner.isLargeScreen).isFalse()
assertThat(positioner.insets).isEqualTo(insets)
}
@Test
fun testShowBubblesVertically_phonePortrait() {
positioner.update(defaultDeviceConfig)
assertThat(positioner.showBubblesVertically()).isFalse()
}
@Test
fun testShowBubblesVertically_phoneLandscape() {
positioner.update(defaultDeviceConfig.copy(isLandscape = true))
assertThat(positioner.isLandscape).isTrue()
assertThat(positioner.showBubblesVertically()).isTrue()
}
@Test
fun testShowBubblesVertically_tablet() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true))
assertThat(positioner.showBubblesVertically()).isTrue()
}
/** If a resting position hasn't been set, calling it will return the default position. */
@Test
fun testGetRestingPosition_returnsDefaultPosition() {
positioner.update(defaultDeviceConfig)
val restingPosition = positioner.getRestingPosition()
val defaultPosition = positioner.defaultStartPosition
assertThat(restingPosition).isEqualTo(defaultPosition)
}
/** If a resting position has been set, it'll return that instead of the default position. */
@Test
fun testGetRestingPosition_returnsRestingPosition() {
positioner.update(defaultDeviceConfig)
val restingPosition = PointF(100f, 100f)
positioner.restingPosition = restingPosition
assertThat(positioner.getRestingPosition()).isEqualTo(restingPosition)
}
/** Test that the default resting position on phone is in upper left. */
@Test
fun testGetRestingPosition_bubble_onPhone() {
positioner.update(defaultDeviceConfig)
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val restingPosition = positioner.getRestingPosition()
assertThat(restingPosition.x).isEqualTo(allowableStackRegion.left)
assertThat(restingPosition.y).isEqualTo(defaultYPosition)
}
@Test
fun testGetRestingPosition_bubble_onPhone_RTL() {
positioner.update(defaultDeviceConfig.copy(isRtl = true))
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val restingPosition = positioner.getRestingPosition()
assertThat(restingPosition.x).isEqualTo(allowableStackRegion.right)
assertThat(restingPosition.y).isEqualTo(defaultYPosition)
}
/** Test that the default resting position on tablet is middle left. */
@Test
fun testGetRestingPosition_chatBubble_onTablet() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true))
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val restingPosition = positioner.getRestingPosition()
assertThat(restingPosition.x).isEqualTo(allowableStackRegion.left)
assertThat(restingPosition.y).isEqualTo(defaultYPosition)
}
@Test
fun testGetRestingPosition_chatBubble_onTablet_RTL() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true, isRtl = true))
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val restingPosition = positioner.getRestingPosition()
assertThat(restingPosition.x).isEqualTo(allowableStackRegion.right)
assertThat(restingPosition.y).isEqualTo(defaultYPosition)
}
/** Test that the default resting position on tablet is middle right. */
@Test
fun testGetDefaultPosition_appBubble_onTablet() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true))
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val startPosition = positioner.getDefaultStartPosition(true /* isAppBubble */)
assertThat(startPosition.x).isEqualTo(allowableStackRegion.right)
assertThat(startPosition.y).isEqualTo(defaultYPosition)
}
@Test
fun testGetRestingPosition_appBubble_onTablet_RTL() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true, isRtl = true))
val allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val startPosition = positioner.getDefaultStartPosition(true /* isAppBubble */)
assertThat(startPosition.x).isEqualTo(allowableStackRegion.left)
assertThat(startPosition.y).isEqualTo(defaultYPosition)
}
@Test
fun testGetRestingPosition_afterBoundsChange() {
positioner.update(
defaultDeviceConfig.copy(isLargeScreen = true, windowBounds = Rect(0, 0, 2000, 1600))
)
// Set the resting position to the right side
var allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
val restingPosition = PointF(allowableStackRegion.right, allowableStackRegion.centerY())
positioner.restingPosition = restingPosition
// Now make the device smaller
positioner.update(
defaultDeviceConfig.copy(isLargeScreen = false, windowBounds = Rect(0, 0, 1000, 1600))
)
// Check the resting position is on the correct side
allowableStackRegion = positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
assertThat(positioner.restingPosition.x).isEqualTo(allowableStackRegion.right)
}
@Test
fun testHasUserModifiedDefaultPosition_false() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true, isRtl = true))
assertThat(positioner.hasUserModifiedDefaultPosition()).isFalse()
positioner.restingPosition = positioner.defaultStartPosition
assertThat(positioner.hasUserModifiedDefaultPosition()).isFalse()
}
@Test
fun testHasUserModifiedDefaultPosition_true() {
positioner.update(defaultDeviceConfig.copy(isLargeScreen = true, isRtl = true))
assertThat(positioner.hasUserModifiedDefaultPosition()).isFalse()
positioner.restingPosition = PointF(0f, 100f)
assertThat(positioner.hasUserModifiedDefaultPosition()).isTrue()
}
@Test
fun testBubbleBarExpandedViewHeightAndWidth() {
val deviceConfig =
defaultDeviceConfig.copy(
// portrait orientation
isLandscape = false,
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.setShowingInBubbleBar(true)
positioner.update(deviceConfig)
positioner.bubbleBarTopOnScreen = 2500
val spaceBetweenTopInsetAndBubbleBarInLandscape = 1680
val expandedViewVerticalSpacing =
resources.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding)
val expectedHeight =
spaceBetweenTopInsetAndBubbleBarInLandscape - 2 * expandedViewVerticalSpacing
val expectedWidth = resources.getDimensionPixelSize(R.dimen.bubble_bar_expanded_view_width)
assertThat(positioner.getExpandedViewWidthForBubbleBar(false)).isEqualTo(expectedWidth)
assertThat(positioner.getExpandedViewHeightForBubbleBar(false)).isEqualTo(expectedHeight)
}
@Test
fun testBubbleBarExpandedViewHeightAndWidth_screenWidthTooSmall() {
val screenWidth = 300
val deviceConfig =
defaultDeviceConfig.copy(
// portrait orientation
isLandscape = false,
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, screenWidth, 2600)
)
positioner.setShowingInBubbleBar(true)
positioner.update(deviceConfig)
positioner.bubbleBarTopOnScreen = 2500
val spaceBetweenTopInsetAndBubbleBarInLandscape = 180
val expandedViewSpacing =
resources.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding)
val expectedHeight = spaceBetweenTopInsetAndBubbleBarInLandscape - 2 * expandedViewSpacing
val expectedWidth = screenWidth - 15 /* horizontal insets */ - 2 * expandedViewSpacing
assertThat(positioner.getExpandedViewWidthForBubbleBar(false)).isEqualTo(expectedWidth)
assertThat(positioner.getExpandedViewHeightForBubbleBar(false)).isEqualTo(expectedHeight)
}
@Test
fun testGetExpandedViewHeight_max() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
assertThat(positioner.getExpandedViewHeight(bubble)).isEqualTo(MAX_HEIGHT)
}
@Test
fun testGetExpandedViewHeight_customHeight_valid() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val minHeight =
context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_default_height)
val bubble =
Bubble(
"key",
ShortcutInfo.Builder(context, "id").build(),
minHeight + 100 /* desiredHeight */,
0 /* desiredHeightResId */,
"title",
0 /* taskId */,
null /* locus */,
true /* isDismissable */,
directExecutor()
) {}
// Ensure the height is the same as the desired value
assertThat(positioner.getExpandedViewHeight(bubble))
.isEqualTo(bubble.getDesiredHeight(context))
}
@Test
fun testGetExpandedViewHeight_customHeight_tooSmall() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val bubble =
Bubble(
"key",
ShortcutInfo.Builder(context, "id").build(),
10 /* desiredHeight */,
0 /* desiredHeightResId */,
"title",
0 /* taskId */,
null /* locus */,
true /* isDismissable */,
directExecutor()
) {}
// Ensure the height is the same as the desired value
val minHeight =
context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_default_height)
assertThat(positioner.getExpandedViewHeight(bubble)).isEqualTo(minHeight)
}
@Test
fun testGetMaxExpandedViewHeight_onLargeTablet() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val manageButtonHeight =
context.resources.getDimensionPixelSize(R.dimen.bubble_manage_button_height)
val pointerWidth = context.resources.getDimensionPixelSize(R.dimen.bubble_pointer_width)
val expandedViewPadding =
context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding)
val expectedHeight =
1800 - 2 * 20 - manageButtonHeight - pointerWidth - expandedViewPadding * 2
assertThat(positioner.getMaxExpandedViewHeight(false /* isOverflow */))
.isEqualTo(expectedHeight)
}
@Test
fun testAreBubblesBottomAligned_largeScreen_true() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
assertThat(positioner.areBubblesBottomAligned()).isTrue()
}
@Test
fun testAreBubblesBottomAligned_largeScreen_landscape_false() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
isLandscape = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
assertThat(positioner.areBubblesBottomAligned()).isFalse()
}
@Test
fun testAreBubblesBottomAligned_smallTablet_false() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
isSmallTablet = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
assertThat(positioner.areBubblesBottomAligned()).isFalse()
}
@Test
fun testAreBubblesBottomAligned_phone_false() {
val deviceConfig =
defaultDeviceConfig.copy(
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
assertThat(positioner.areBubblesBottomAligned()).isFalse()
}
@Test
fun testExpandedViewY_phoneLandscape() {
val deviceConfig =
defaultDeviceConfig.copy(
isLandscape = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
// This bubble will have max height so it'll always be top aligned
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(positioner.getExpandedViewYTopAligned())
}
@Test
fun testExpandedViewY_phonePortrait() {
val deviceConfig =
defaultDeviceConfig.copy(
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
// Always top aligned in phone portrait
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(positioner.getExpandedViewYTopAligned())
}
@Test
fun testExpandedViewY_smallTabletLandscape() {
val deviceConfig =
defaultDeviceConfig.copy(
isSmallTablet = true,
isLandscape = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
// This bubble will have max height which is always top aligned on small tablets
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(positioner.getExpandedViewYTopAligned())
}
@Test
fun testExpandedViewY_smallTabletPortrait() {
val deviceConfig =
defaultDeviceConfig.copy(
isSmallTablet = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
// This bubble will have max height which is always top aligned on small tablets
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(positioner.getExpandedViewYTopAligned())
}
@Test
fun testExpandedViewY_largeScreenLandscape() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
isLandscape = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
// This bubble will have max height which is always top aligned on landscape, large tablet
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(positioner.getExpandedViewYTopAligned())
}
@Test
fun testExpandedViewY_largeScreenPortrait() {
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
insets = Insets.of(10, 20, 5, 15),
windowBounds = Rect(0, 0, 1800, 2600)
)
positioner.update(deviceConfig)
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), null, directExecutor())
val manageButtonHeight =
context.resources.getDimensionPixelSize(R.dimen.bubble_manage_button_height)
val manageButtonPlusMargin =
manageButtonHeight +
2 * context.resources.getDimensionPixelSize(R.dimen.bubble_manage_button_margin)
val pointerWidth = context.resources.getDimensionPixelSize(R.dimen.bubble_pointer_width)
val expectedExpandedViewY =
positioner.availableRect.bottom -
manageButtonPlusMargin -
positioner.getExpandedViewHeightForLargeScreen() -
pointerWidth
// Bubbles are bottom aligned on portrait, large tablet
assertThat(positioner.getExpandedViewY(bubble, 0f /* bubblePosition */))
.isEqualTo(expectedExpandedViewY)
}
@Test
fun testGetTaskViewContentWidth_onLeft() {
positioner.update(defaultDeviceConfig.copy(insets = Insets.of(100, 0, 200, 0)))
val taskViewWidth = positioner.getTaskViewContentWidth(true /* onLeft */)
val paddings =
positioner.getExpandedViewContainerPadding(true /* onLeft */, false /* isOverflow */)
assertThat(taskViewWidth)
.isEqualTo(positioner.screenRect.width() - paddings[0] - paddings[2])
}
@Test
fun testGetTaskViewContentWidth_onRight() {
positioner.update(defaultDeviceConfig.copy(insets = Insets.of(100, 0, 200, 0)))
val taskViewWidth = positioner.getTaskViewContentWidth(false /* onLeft */)
val paddings =
positioner.getExpandedViewContainerPadding(false /* onLeft */, false /* isOverflow */)
assertThat(taskViewWidth)
.isEqualTo(positioner.screenRect.width() - paddings[0] - paddings[2])
}
@Test
fun testIsBubbleBarOnLeft_defaultsToRight() {
positioner.bubbleBarLocation = BubbleBarLocation.DEFAULT
assertThat(positioner.isBubbleBarOnLeft).isFalse()
// Check that left and right return expected position
positioner.bubbleBarLocation = BubbleBarLocation.LEFT
assertThat(positioner.isBubbleBarOnLeft).isTrue()
positioner.bubbleBarLocation = BubbleBarLocation.RIGHT
assertThat(positioner.isBubbleBarOnLeft).isFalse()
}
@Test
fun testIsBubbleBarOnLeft_rtlEnabled_defaultsToLeft() {
positioner.update(defaultDeviceConfig.copy(isRtl = true))
positioner.bubbleBarLocation = BubbleBarLocation.DEFAULT
assertThat(positioner.isBubbleBarOnLeft).isTrue()
// Check that left and right return expected position
positioner.bubbleBarLocation = BubbleBarLocation.LEFT
assertThat(positioner.isBubbleBarOnLeft).isTrue()
positioner.bubbleBarLocation = BubbleBarLocation.RIGHT
assertThat(positioner.isBubbleBarOnLeft).isFalse()
}
@Test
fun testGetBubbleBarExpandedViewBounds_onLeft() {
testGetBubbleBarExpandedViewBounds(onLeft = true, isOverflow = false)
}
@Test
fun testGetBubbleBarExpandedViewBounds_onRight() {
testGetBubbleBarExpandedViewBounds(onLeft = false, isOverflow = false)
}
@Test
fun testGetBubbleBarExpandedViewBounds_isOverflow_onLeft() {
testGetBubbleBarExpandedViewBounds(onLeft = true, isOverflow = true)
}
@Test
fun testGetBubbleBarExpandedViewBounds_isOverflow_onRight() {
testGetBubbleBarExpandedViewBounds(onLeft = false, isOverflow = true)
}
private fun testGetBubbleBarExpandedViewBounds(onLeft: Boolean, isOverflow: Boolean) {
positioner.setShowingInBubbleBar(true)
val windowBounds = Rect(0, 0, 2000, 2600)
val insets = Insets.of(10, 20, 5, 15)
val deviceConfig =
defaultDeviceConfig.copy(
isLargeScreen = true,
isLandscape = true,
insets = insets,
windowBounds = windowBounds
)
positioner.update(deviceConfig)
val bubbleBarHeight = 100
positioner.bubbleBarTopOnScreen = windowBounds.bottom - insets.bottom - bubbleBarHeight
val expandedViewPadding =
context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding)
val left: Int
val right: Int
if (onLeft) {
// Pin to the left, calculate right
left = deviceConfig.insets.left + expandedViewPadding
right = left + positioner.getExpandedViewWidthForBubbleBar(isOverflow)
} else {
// Pin to the right, calculate left
right =
deviceConfig.windowBounds.right - deviceConfig.insets.right - expandedViewPadding
left = right - positioner.getExpandedViewWidthForBubbleBar(isOverflow)
}
// Above the bubble bar
val bottom = positioner.bubbleBarTopOnScreen - expandedViewPadding
// Calculate right and top based on size
val top = bottom - positioner.getExpandedViewHeightForBubbleBar(isOverflow)
val expectedBounds = Rect(left, top, right, bottom)
val bounds = Rect()
positioner.getBubbleBarExpandedViewBounds(onLeft, isOverflow, bounds)
assertThat(bounds).isEqualTo(expectedBounds)
}
private val defaultYPosition: Float
/**
* Calculates the Y position bubbles should be placed based on the config. Based on the
* calculations in [BubblePositioner.getDefaultStartPosition] and
* [BubbleStackView.RelativeStackPosition].
*/
get() {
val isTablet = positioner.isLargeScreen
// On tablet the position is centered, on phone it is an offset from the top.
val desiredY =
if (isTablet) {
positioner.screenRect.height() / 2f - positioner.bubbleSize / 2f
} else {
context.resources
.getDimensionPixelOffset(R.dimen.bubble_stack_starting_offset_y)
.toFloat()
}
// Since we're visually centering the bubbles on tablet, use total screen height rather
// than the available height.
val height =
if (isTablet) {
positioner.screenRect.height()
} else {
positioner.availableRect.height()
}
val offsetPercent = (desiredY / height).coerceIn(0f, 1f)
val allowableStackRegion =
positioner.getAllowableStackPositionRegion(1 /* bubbleCount */)
return allowableStackRegion.top + allowableStackRegion.height() * offsetPercent
}
}
@@ -1,462 +0,0 @@
/*
* 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.wm.shell.bubbles
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.res.Resources
import android.graphics.Color
import android.graphics.drawable.Icon
import android.os.UserHandle
import android.platform.test.flag.junit.SetFlagsRule
import android.view.IWindowManager
import android.view.WindowManager
import android.view.WindowManagerGlobal
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.android.internal.logging.testing.UiEventLoggerFake
import com.android.internal.protolog.common.ProtoLog
import com.android.launcher3.icons.BubbleIconFactory
import com.android.wm.shell.Flags
import com.android.wm.shell.R
import com.android.wm.shell.bubbles.Bubbles.SysuiProxy
import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix
import com.android.wm.shell.common.FloatingContentCoordinator
import com.android.wm.shell.common.ShellExecutor
import com.android.wm.shell.shared.animation.PhysicsAnimatorTestUtils
import com.android.wm.shell.taskview.TaskView
import com.android.wm.shell.taskview.TaskViewTaskController
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.MoreExecutors.directExecutor
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
/** Unit tests for [BubbleStackView]. */
@SmallTest
@RunWith(AndroidJUnit4::class)
class BubbleStackViewTest {
@get:Rule val setFlagsRule = SetFlagsRule()
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var positioner: BubblePositioner
private lateinit var iconFactory: BubbleIconFactory
private lateinit var expandedViewManager: FakeBubbleExpandedViewManager
private lateinit var bubbleStackView: BubbleStackView
private lateinit var shellExecutor: ShellExecutor
private lateinit var windowManager: IWindowManager
private lateinit var bubbleTaskViewFactory: BubbleTaskViewFactory
private lateinit var bubbleData: BubbleData
private lateinit var bubbleStackViewManager: FakeBubbleStackViewManager
private var sysuiProxy = mock<SysuiProxy>()
@Before
fun setUp() {
PhysicsAnimatorTestUtils.prepareForTest()
// Disable protolog tool when running the tests from studio
ProtoLog.REQUIRE_PROTOLOGTOOL = false
windowManager = WindowManagerGlobal.getWindowManagerService()!!
shellExecutor = TestShellExecutor()
val windowManager = context.getSystemService(WindowManager::class.java)
iconFactory =
BubbleIconFactory(
context,
context.resources.getDimensionPixelSize(R.dimen.bubble_size),
context.resources.getDimensionPixelSize(R.dimen.bubble_badge_size),
Color.BLACK,
context.resources.getDimensionPixelSize(
com.android.internal.R.dimen.importance_ring_stroke_width
)
)
positioner = BubblePositioner(context, windowManager)
bubbleData =
BubbleData(
context,
BubbleLogger(UiEventLoggerFake()),
positioner,
BubbleEducationController(context),
shellExecutor
)
bubbleStackViewManager = FakeBubbleStackViewManager()
expandedViewManager = FakeBubbleExpandedViewManager()
bubbleTaskViewFactory = FakeBubbleTaskViewFactory()
bubbleStackView =
BubbleStackView(
context,
bubbleStackViewManager,
positioner,
bubbleData,
null,
FloatingContentCoordinator(),
{ sysuiProxy },
shellExecutor
)
context
.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
.edit()
.putBoolean(StackEducationView.PREF_STACK_EDUCATION, true)
.apply()
}
@After
fun tearDown() {
PhysicsAnimatorTestUtils.tearDown()
}
@Test
fun addBubble() {
val bubble = createAndInflateBubble()
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.addBubble(bubble)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
assertThat(bubbleStackView.bubbleCount).isEqualTo(1)
}
@Test
fun tapBubbleToExpand() {
val bubble = createAndInflateBubble()
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.addBubble(bubble)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
assertThat(bubbleStackView.bubbleCount).isEqualTo(1)
var lastUpdate: BubbleData.Update? = null
val semaphore = Semaphore(0)
val listener =
BubbleData.Listener { update ->
lastUpdate = update
semaphore.release()
}
bubbleData.setListener(listener)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubble.iconView!!.performClick()
// we're checking the expanded state in BubbleData because that's the source of truth.
// This will eventually propagate an update back to the stack view, but setting the
// entire pipeline is outside the scope of a unit test.
assertThat(bubbleData.isExpanded).isTrue()
}
assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue()
assertThat(lastUpdate).isNotNull()
assertThat(lastUpdate!!.expandedChanged).isTrue()
assertThat(lastUpdate!!.expanded).isTrue()
}
@Test
fun tapDifferentBubble_shouldReorder() {
val bubble1 = createAndInflateChatBubble(key = "bubble1")
val bubble2 = createAndInflateChatBubble(key = "bubble2")
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.addBubble(bubble1)
bubbleStackView.addBubble(bubble2)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
assertThat(bubbleStackView.bubbleCount).isEqualTo(2)
assertThat(bubbleData.bubbles).hasSize(2)
assertThat(bubbleData.selectedBubble).isEqualTo(bubble2)
assertThat(bubble2.iconView).isNotNull()
var lastUpdate: BubbleData.Update? = null
val semaphore = Semaphore(0)
val listener =
BubbleData.Listener { update ->
lastUpdate = update
semaphore.release()
}
bubbleData.setListener(listener)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubble2.iconView!!.performClick()
assertThat(bubbleData.isExpanded).isTrue()
bubbleStackView.setSelectedBubble(bubble2)
bubbleStackView.isExpanded = true
}
assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue()
assertThat(lastUpdate!!.expanded).isTrue()
assertThat(lastUpdate!!.bubbles.map { it.key })
.containsExactly("bubble2", "bubble1")
.inOrder()
// wait for idle to allow the animation to start
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// wait for the expansion animation to complete before interacting with the bubbles
PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(
AnimatableScaleMatrix.SCALE_X, AnimatableScaleMatrix.SCALE_Y)
// tap on bubble1 to select it
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubble1.iconView!!.performClick()
}
assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue()
assertThat(bubbleData.selectedBubble).isEqualTo(bubble1)
// tap on bubble1 again to collapse the stack
InstrumentationRegistry.getInstrumentation().runOnMainSync {
// we have to set the selected bubble in the stack view manually because we don't have a
// listener wired up.
bubbleStackView.setSelectedBubble(bubble1)
bubble1.iconView!!.performClick()
}
assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue()
assertThat(bubbleData.selectedBubble).isEqualTo(bubble1)
assertThat(bubbleData.isExpanded).isFalse()
assertThat(lastUpdate!!.orderChanged).isTrue()
assertThat(lastUpdate!!.bubbles.map { it.key })
.containsExactly("bubble1", "bubble2")
.inOrder()
}
@EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun testCreateStackView_noOverflowContents_noOverflow() {
bubbleStackView =
BubbleStackView(
context,
bubbleStackViewManager,
positioner,
bubbleData,
null,
FloatingContentCoordinator(),
{ sysuiProxy },
shellExecutor
)
assertThat(bubbleData.overflowBubbles).isEmpty()
val bubbleOverflow = bubbleData.overflow
// Overflow shouldn't be attached
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1)
}
@EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun testCreateStackView_hasOverflowContents_hasOverflow() {
// Add a bubble to the overflow
val bubble1 = createAndInflateChatBubble(key = "bubble1")
bubbleData.notificationEntryUpdated(bubble1, false, false)
bubbleData.dismissBubbleWithKey(bubble1.key, Bubbles.DISMISS_USER_GESTURE)
assertThat(bubbleData.overflowBubbles).isNotEmpty()
bubbleStackView =
BubbleStackView(
context,
bubbleStackViewManager,
positioner,
bubbleData,
null,
FloatingContentCoordinator(),
{ sysuiProxy },
shellExecutor
)
val bubbleOverflow = bubbleData.overflow
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1)
}
@DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun testCreateStackView_noOverflowContents_hasOverflow() {
bubbleStackView =
BubbleStackView(
context,
bubbleStackViewManager,
positioner,
bubbleData,
null,
FloatingContentCoordinator(),
{ sysuiProxy },
shellExecutor
)
assertThat(bubbleData.overflowBubbles).isEmpty()
val bubbleOverflow = bubbleData.overflow
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1)
}
@EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun showOverflow_true() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.showOverflow(true)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val bubbleOverflow = bubbleData.overflow
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1)
}
@EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun showOverflow_false() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.showOverflow(true)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val bubbleOverflow = bubbleData.overflow
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.showOverflow(false)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// The overflow should've been removed
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1)
}
@DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW)
@Test
fun showOverflow_ignored() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
bubbleStackView.showOverflow(false)
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
// showOverflow should've been ignored, so the overflow would be attached
val bubbleOverflow = bubbleData.overflow
assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1)
}
private fun createAndInflateChatBubble(key: String): Bubble {
val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button)
val shortcutInfo = ShortcutInfo.Builder(context, "fakeId").setIcon(icon).build()
val bubble =
Bubble(
key,
shortcutInfo,
/* desiredHeight= */ 6,
Resources.ID_NULL,
"title",
/* taskId= */ 0,
"locus",
/* isDismissable= */ true,
directExecutor()
) {}
inflateBubble(bubble)
return bubble
}
private fun createAndInflateBubble(): Bubble {
val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName)
val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button)
val bubble = Bubble.createAppBubble(intent, UserHandle(1), icon, directExecutor())
inflateBubble(bubble)
return bubble
}
private fun inflateBubble(bubble: Bubble) {
bubble.setInflateSynchronously(true)
bubbleData.notificationEntryUpdated(bubble, true, false)
val semaphore = Semaphore(0)
val callback: BubbleViewInfoTask.Callback =
BubbleViewInfoTask.Callback { semaphore.release() }
bubble.inflate(
callback,
context,
expandedViewManager,
bubbleTaskViewFactory,
positioner,
bubbleStackView,
null,
iconFactory,
false
)
assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue()
assertThat(bubble.isInflated).isTrue()
}
private class FakeBubbleStackViewManager : BubbleStackViewManager {
override fun onAllBubblesAnimatedOut() {}
override fun updateWindowFlagsForBackpress(interceptBack: Boolean) {}
override fun checkNotificationPanelExpandedState(callback: Consumer<Boolean>) {}
override fun hideCurrentInputMethod() {}
}
private class TestShellExecutor : ShellExecutor {
override fun execute(runnable: Runnable) {
runnable.run()
}
override fun executeDelayed(r: Runnable, delayMillis: Long) {
r.run()
}
override fun removeCallbacks(r: Runnable?) {}
override fun hasCallback(r: Runnable): Boolean = false
}
private inner class FakeBubbleTaskViewFactory : BubbleTaskViewFactory {
override fun create(): BubbleTaskView {
val taskViewTaskController = mock<TaskViewTaskController>()
val taskView = TaskView(context, taskViewTaskController)
return BubbleTaskView(taskView, shellExecutor)
}
}
private inner class FakeBubbleExpandedViewManager : BubbleExpandedViewManager {
override val overflowBubbles: List<Bubble>
get() = emptyList()
override fun setOverflowListener(listener: BubbleData.Listener) {}
override fun collapseStack() {}
override fun updateWindowFlagsForBackpress(intercept: Boolean) {}
override fun promoteBubbleFromOverflow(bubble: Bubble) {}
override fun removeBubble(key: String, reason: Int) {}
override fun dismissBubble(bubble: Bubble, reason: Int) {}
override fun setAppBubbleTaskId(key: String, taskId: Int) {}
override fun isStackExpanded(): Boolean = false
override fun isShowingAsBubbleBar(): Boolean = false
override fun hideCurrentInputMethod() {}
}
}
@@ -1,92 +0,0 @@
/*
* 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.wm.shell.bubbles
import android.content.ComponentName
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.wm.shell.taskview.TaskView
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.MoreExecutors.directExecutor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
@SmallTest
@RunWith(AndroidJUnit4::class)
class BubbleTaskViewTest {
private lateinit var bubbleTaskView: BubbleTaskView
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var taskView: TaskView
@Before
fun setUp() {
taskView = mock()
bubbleTaskView = BubbleTaskView(taskView, directExecutor())
}
@Test
fun onTaskCreated_updatesState() {
val componentName = ComponentName(context, "TestClass")
bubbleTaskView.listener.onTaskCreated(123, componentName)
assertThat(bubbleTaskView.taskId).isEqualTo(123)
assertThat(bubbleTaskView.componentName).isEqualTo(componentName)
assertThat(bubbleTaskView.isCreated).isTrue()
}
@Test
fun onTaskCreated_callsDelegateListener() {
var actualTaskId = -1
var actualComponentName: ComponentName? = null
val delegateListener = object : TaskView.Listener {
override fun onTaskCreated(taskId: Int, name: ComponentName) {
actualTaskId = taskId
actualComponentName = name
}
}
bubbleTaskView.delegateListener = delegateListener
val componentName = ComponentName(context, "TestClass")
bubbleTaskView.listener.onTaskCreated(123, componentName)
assertThat(actualTaskId).isEqualTo(123)
assertThat(actualComponentName).isEqualTo(componentName)
}
@Test
fun cleanup_invalidTaskId_doesNotRemoveTask() {
bubbleTaskView.cleanup()
verify(taskView, never()).removeTask()
}
@Test
fun cleanup_validTaskId_removesTask() {
val componentName = ComponentName(context, "TestClass")
bubbleTaskView.listener.onTaskCreated(123, componentName)
bubbleTaskView.cleanup()
verify(taskView).removeTask()
}
}
@@ -1,459 +0,0 @@
/*
* 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.wm.shell.bubbles.bar
import android.content.Context
import android.graphics.Insets
import android.graphics.PointF
import android.graphics.Rect
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.core.animation.AnimatorTestRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.android.internal.protolog.common.ProtoLog
import com.android.wm.shell.R
import com.android.wm.shell.bubbles.BubblePositioner
import com.android.wm.shell.bubbles.DeviceConfig
import com.android.wm.shell.common.bubbles.BaseBubblePinController
import com.android.wm.shell.common.bubbles.BaseBubblePinController.Companion.DROP_TARGET_ALPHA_IN_DURATION
import com.android.wm.shell.common.bubbles.BaseBubblePinController.Companion.DROP_TARGET_ALPHA_OUT_DURATION
import com.android.wm.shell.common.bubbles.BubbleBarLocation
import com.android.wm.shell.common.bubbles.BubbleBarLocation.LEFT
import com.android.wm.shell.common.bubbles.BubbleBarLocation.RIGHT
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.ClassRule
import org.junit.Test
import org.junit.runner.RunWith
/** Tests for [BubbleExpandedViewPinController] */
@SmallTest
@RunWith(AndroidJUnit4::class)
class BubbleExpandedViewPinControllerTest {
companion object {
@JvmField @ClassRule val animatorTestRule: AnimatorTestRule = AnimatorTestRule()
const val SCREEN_WIDTH = 2000
const val SCREEN_HEIGHT = 1000
const val BUBBLE_BAR_HEIGHT = 50
}
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var positioner: BubblePositioner
private lateinit var container: FrameLayout
private lateinit var controller: BubbleExpandedViewPinController
private lateinit var testListener: TestLocationChangeListener
private val dropTargetView: View?
get() = container.findViewById(R.id.bubble_bar_drop_target)
private val pointOnLeft = PointF(100f, 100f)
private val pointOnRight = PointF(1900f, 500f)
@Before
fun setUp() {
ProtoLog.REQUIRE_PROTOLOGTOOL = false
container = FrameLayout(context)
val windowManager = context.getSystemService(WindowManager::class.java)
positioner = BubblePositioner(context, windowManager)
positioner.setShowingInBubbleBar(true)
val deviceConfig =
DeviceConfig(
windowBounds = Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT),
isLargeScreen = true,
isSmallTablet = false,
isLandscape = true,
isRtl = false,
insets = Insets.of(10, 20, 30, 40)
)
positioner.update(deviceConfig)
positioner.bubbleBarTopOnScreen =
SCREEN_HEIGHT - deviceConfig.insets.bottom - BUBBLE_BAR_HEIGHT
controller = BubbleExpandedViewPinController(context, container, positioner)
testListener = TestLocationChangeListener()
controller.setListener(testListener)
}
@After
fun tearDown() {
getInstrumentation().runOnMainSync { controller.onDragEnd() }
waitForAnimateOut()
}
/** Dragging on same side should not show drop target or trigger location changes */
@Test
fun drag_stayOnRightSide() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onDragEnd()
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).containsExactly(RIGHT)
}
/** Dragging on same side should not show drop target or trigger location changes */
@Test
fun drag_stayOnLeftSide() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onDragEnd()
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).containsExactly(LEFT)
}
/** Drag crosses to the other side. Show drop target and trigger a location change. */
@Test
fun drag_rightToLeft() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnLeft())
assertThat(testListener.locationChanges).containsExactly(LEFT)
assertThat(testListener.locationReleases).isEmpty()
}
/** Drag crosses to the other side. Show drop target and trigger a location change. */
@Test
fun drag_leftToRight() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnRight())
assertThat(testListener.locationChanges).containsExactly(RIGHT)
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drop target does not initially show on the side that the drag starts. Check that it shows up
* after the dragging the view to other side and back to the initial side.
*/
@Test
fun drag_rightToLeftToRight() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
getInstrumentation().runOnMainSync { controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y) }
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
getInstrumentation().runOnMainSync {
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateOut()
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnRight())
assertThat(testListener.locationChanges).containsExactly(LEFT, RIGHT).inOrder()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drop target does not initially show on the side that the drag starts. Check that it shows up
* after the dragging the view to other side and back to the initial side.
*/
@Test
fun drag_leftToRightToLeft() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
getInstrumentation().runOnMainSync {
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
getInstrumentation().runOnMainSync { controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y) }
waitForAnimateOut()
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnLeft())
assertThat(testListener.locationChanges).containsExactly(RIGHT, LEFT).inOrder()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag from right to left, but stay in exclusion rect around the dismiss view. Drop target
* should not show and location change should not trigger.
*/
@Test
fun drag_rightToLeft_inExclusionRect() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
// Exclusion rect is around the bottom center area of the screen
controller.onDragUpdate(SCREEN_WIDTH / 2f - 50, SCREEN_HEIGHT - 100f)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag from left to right, but stay in exclusion rect around the dismiss view. Drop target
* should not show and location change should not trigger.
*/
@Test
fun drag_leftToRight_inExclusionRect() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
// Exclusion rect is around the bottom center area of the screen
controller.onDragUpdate(SCREEN_WIDTH / 2f + 50, SCREEN_HEIGHT - 100f)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag to dismiss target and back to the same side should not cause the drop target to show.
*/
@Test
fun drag_rightToDismissToRight() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onStuckToDismissTarget()
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag to dismiss target and back to the same side should not cause the drop target to show.
*/
@Test
fun drag_leftToDismissToLeft() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onStuckToDismissTarget()
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).isEmpty()
assertThat(testListener.locationReleases).isEmpty()
}
/** Drag to dismiss target and other side should show drop target on the other side. */
@Test
fun drag_rightToDismissToLeft() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onStuckToDismissTarget()
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnLeft())
assertThat(testListener.locationChanges).containsExactly(LEFT)
assertThat(testListener.locationReleases).isEmpty()
}
/** Drag to dismiss target and other side should show drop target on the other side. */
@Test
fun drag_leftToDismissToRight() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onStuckToDismissTarget()
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
assertThat(dropTargetView!!.bounds()).isEqualTo(getExpectedDropTargetBoundsOnRight())
assertThat(testListener.locationChanges).containsExactly(RIGHT)
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag to dismiss should trigger a location change to the initial location, if the current
* location is different. And hide the drop target.
*/
@Test
fun drag_rightToLeftToDismiss() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
getInstrumentation().runOnMainSync { controller.onStuckToDismissTarget() }
waitForAnimateOut()
assertThat(dropTargetView!!.alpha).isEqualTo(0f)
assertThat(testListener.locationChanges).containsExactly(LEFT, RIGHT).inOrder()
assertThat(testListener.locationReleases).isEmpty()
}
/**
* Drag to dismiss should trigger a location change to the initial location, if the current
* location is different. And hide the drop target.
*/
@Test
fun drag_leftToRightToDismiss() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
assertThat(dropTargetView!!.alpha).isEqualTo(1f)
getInstrumentation().runOnMainSync { controller.onStuckToDismissTarget() }
waitForAnimateOut()
assertThat(dropTargetView!!.alpha).isEqualTo(0f)
assertThat(testListener.locationChanges).containsExactly(RIGHT, LEFT).inOrder()
assertThat(testListener.locationReleases).isEmpty()
}
/** Finishing drag should remove drop target and send location update. */
@Test
fun drag_rightToLeftRelease() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = false)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
getInstrumentation().runOnMainSync { controller.onDragEnd() }
waitForAnimateOut()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).containsExactly(LEFT)
assertThat(testListener.locationReleases).containsExactly(LEFT)
}
/** Finishing drag should remove drop target and send location update. */
@Test
fun drag_leftToRightRelease() {
getInstrumentation().runOnMainSync {
controller.onDragStart(initialLocationOnLeft = true)
controller.onDragUpdate(pointOnLeft.x, pointOnLeft.y)
controller.onDragUpdate(pointOnRight.x, pointOnRight.y)
}
waitForAnimateIn()
assertThat(dropTargetView).isNotNull()
getInstrumentation().runOnMainSync { controller.onDragEnd() }
waitForAnimateOut()
assertThat(dropTargetView).isNull()
assertThat(testListener.locationChanges).containsExactly(RIGHT)
assertThat(testListener.locationReleases).containsExactly(RIGHT)
}
private fun getExpectedDropTargetBoundsOnLeft(): Rect =
Rect().also {
positioner.getBubbleBarExpandedViewBounds(
true /* onLeft */,
false /* isOverflowExpanded */,
it
)
}
private fun getExpectedDropTargetBoundsOnRight(): Rect =
Rect().also {
positioner.getBubbleBarExpandedViewBounds(
false /* onLeft */,
false /* isOverflowExpanded */,
it
)
}
private fun waitForAnimateIn() {
// Advance animator for on-device test
getInstrumentation().runOnMainSync {
animatorTestRule.advanceTimeBy(DROP_TARGET_ALPHA_IN_DURATION)
}
}
private fun waitForAnimateOut() {
// Advance animator for on-device test
getInstrumentation().runOnMainSync {
animatorTestRule.advanceTimeBy(DROP_TARGET_ALPHA_OUT_DURATION)
}
}
private fun View.bounds(): Rect {
return Rect(0, 0, layoutParams.width, layoutParams.height).also { rect ->
rect.offsetTo(x.toInt(), y.toInt())
}
}
internal class TestLocationChangeListener : BaseBubblePinController.LocationChangeListener {
val locationChanges = mutableListOf<BubbleBarLocation>()
val locationReleases = mutableListOf<BubbleBarLocation>()
override fun onChange(location: BubbleBarLocation) {
locationChanges.add(location)
}
override fun onRelease(location: BubbleBarLocation) {
locationReleases.add(location)
}
}
}