diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt index 9cac7727a7f..d77aa80a774 100644 --- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt +++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt @@ -33,6 +33,7 @@ import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT import android.hardware.display.DisplayTopology.TreeNode.POSITION_RIGHT import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP import android.util.Log +import android.view.MotionEvent import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.Button @@ -161,9 +162,41 @@ class TopologyScale( const val PREFERENCE_KEY = "display_topology_preference" -/** dp of padding on each side of a display block. */ +/** Padding in pane coordinate pixels on each side of a display block. */ const val BLOCK_PADDING = 2 +/** Represents a draggable block in the topology pane. */ +class DisplayBlock(context : Context) : Button(context) { + init { + isScrollContainer = false + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + } + + /** Sets position of the block given unpadded coordinates. */ + fun place(topLeft : Point) { + x = (topLeft.x + BLOCK_PADDING).toFloat() + y = (topLeft.y + BLOCK_PADDING).toFloat() + } + + val unpaddedX : Int + get() = (x - BLOCK_PADDING).toInt() + + val unpaddedY : Int + get() = (y - BLOCK_PADDING).toInt() + + /** Sets position and size of the block given unpadded bounds. */ + fun placeAndSize(bounds : RectF, scale : TopologyScale) { + val topLeft = scale.displayToPaneCoor(PointF(bounds.left, bounds.top)) + val bottomRight = scale.displayToPaneCoor(PointF(bounds.right, bounds.bottom)) + val layout = layoutParams + layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2 + layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2 + layoutParams = layout + place(topLeft) + } +} + /** * DisplayTopologyPreference allows the user to change the display topology * when there is one or more extended display attached. @@ -190,7 +223,7 @@ class DisplayTopologyPreference(context : Context) key = PREFERENCE_KEY - injector = Injector() + injector = Injector(context) } override fun onBindViewHolder(holder: PreferenceViewHolder) { @@ -223,74 +256,157 @@ class DisplayTopologyPreference(context : Context) } } - open class Injector { - open fun displayTopology(context : Context) : DisplayTopology? { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - return displayManager.displayTopology + open class Injector(val context : Context) { + /** + * Lazy property for Display Manager, to prevent eagerly getting the service in unit tests. + */ + private val displayManager : DisplayManager by lazy { + context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager } - open fun wallpaper(context : Context) : Drawable { - return WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK) - } + open var displayTopology : DisplayTopology? + get() = displayManager.displayTopology + set(value) { displayManager.displayTopology = value } + + open val wallpaper : Drawable + get() = WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK) } - private fun calcAbsRects( - dest : MutableMap, n : DisplayTopology.TreeNode, x : Float, y : Float) { - dest.put(n.displayId, RectF(x, y, x + n.width, y + n.height)) + /** + * Holds information about the current system topology. + * @param positions list of displays comprised of the display ID and position + */ + private data class TopologyInfo( + val topology: DisplayTopology, val scaling: TopologyScale, + val positions: List>) - for (c in n.children) { - val (xoff, yoff) = when (c.position) { - POSITION_LEFT -> Pair(-c.width, +c.offset) - POSITION_RIGHT -> Pair(+n.width, +c.offset) - POSITION_TOP -> Pair(+c.offset, -c.height) - POSITION_BOTTOM -> Pair(+c.offset, +n.height) - else -> throw IllegalStateException("invalid position for display: ${c}") - } - calcAbsRects(dest, c, x + xoff, y + yoff) + /** + * Holds information about the current drag operation. + * @param stationaryDisps ID and position of displays that are not moving + * @param display View that is currently being dragged + * @param displayId ID of display being dragged + * @param displayWidth width of display being dragged in actual (not View) coordinates + * @param displayHeight height of display being dragged in actual (not View) coordinates + * @param dragOffsetX difference between event rawX coordinate and X of the display in the pane + * @param dragOffsetY difference between event rawY coordinate and Y of the display in the pane + */ + private data class BlockDrag( + val stationaryDisps : List>, + val display: DisplayBlock, val displayId: Int, + val displayWidth: Float, val displayHeight: Float, + val dragOffsetX: Float, val dragOffsetY: Float) + + private var mTopologyInfo : TopologyInfo? = null + private var mDrag : BlockDrag? = null + + @VisibleForTesting fun refreshPane() { + val recycleableBlocks = ArrayDeque() + for (i in 0..mPaneContent.childCount-1) { + recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock) } - } - private fun refreshPane() { - mPaneContent.removeAllViews() - - val root = injector.displayTopology(context)?.root - if (root == null) { + val topology = injector.displayTopology + if (topology == null) { // This occurs when no topology is active. // TODO(b/352648432): show main display or mirrored displays rather than an empty pane. mTopologyHint.text = "" + mPaneContent.removeAllViews() + mTopologyInfo = null return } mTopologyHint.text = context.getString(R.string.external_display_topology_hint) - val blocksPos = buildMap { calcAbsRects(this, root, x = 0f, y = 0f) } + val blocksPos = buildList { + val bounds = topology.absoluteBounds + (0..bounds.size()-1).forEach { + add(Pair(bounds.keyAt(it), bounds.valueAt(it))) + } + } val scaling = TopologyScale( - mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f, blocksPos.values) + mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f, + blocksPos.map { it.second }.toList()) mPaneHolder.layoutParams.let { if (it.height != scaling.paneHeight) { it.height = scaling.paneHeight mPaneHolder.layoutParams = it } } - val wallpaper = injector.wallpaper(context) - blocksPos.values.forEach { p -> - Button(context).apply { - isScrollContainer = false - isVerticalScrollBarEnabled = false - isHorizontalScrollBarEnabled = false - background = wallpaper - val topLeft = scaling.displayToPaneCoor(PointF(p.left, p.top)) - val bottomRight = scaling.displayToPaneCoor(PointF(p.right, p.bottom)) + + blocksPos.forEach { (id, pos) -> + val block = recycleableBlocks.removeFirstOrNull() ?: DisplayBlock(context).apply { + // We need a separate wallpaper Drawable for each display block, since each needs to + // be drawn at a separate size. + background = injector.wallpaper mPaneContent.addView(this) + } - val layout = layoutParams - layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2 - layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2 - layoutParams = layout - x = (topLeft.x + BLOCK_PADDING).toFloat() - y = (topLeft.y + BLOCK_PADDING).toFloat() + block.placeAndSize(pos, scaling) + block.setOnTouchListener { view, ev -> + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> onBlockTouchDown(id, pos, block, ev) + MotionEvent.ACTION_MOVE -> onBlockTouchMove(ev) + MotionEvent.ACTION_UP -> onBlockTouchUp() + else -> false + } } } + mPaneContent.removeViews(blocksPos.size, recycleableBlocks.size) + + mTopologyInfo = TopologyInfo(topology, scaling, blocksPos) + } + + private fun onBlockTouchDown( + displayId: Int, displayPos: RectF, block: DisplayBlock, ev: MotionEvent): Boolean { + val stationaryDisps = (mTopologyInfo ?: return false) + .positions.filter { it.first != displayId } + + // We have to use rawX and rawY for the coordinates since the view receiving the event is + // also the view that is moving. We need coordinates relative to something that isn't + // moving, and the raw coordinates are relative to the screen. + mDrag = BlockDrag( + stationaryDisps.toList(), block, displayId, displayPos.width(), displayPos.height(), + ev.rawX - block.unpaddedX, ev.rawY - block.unpaddedY) + + // Prevents a container of this view from intercepting the touch events in the case the + // pointer moves outside of the display block or the pane. + mPaneContent.requestDisallowInterceptTouchEvent(true) + return true + } + + private fun onBlockTouchMove(ev: MotionEvent): Boolean { + val drag = mDrag ?: return false + val topology = mTopologyInfo ?: return false + val dispDragCoor = topology.scaling.paneToDisplayCoor(Point( + (ev.rawX - drag.dragOffsetX).toInt(), + (ev.rawY - drag.dragOffsetY).toInt())) + val dispDragRect = RectF( + dispDragCoor.x, dispDragCoor.y, + dispDragCoor.x + drag.displayWidth, dispDragCoor.y + drag.displayHeight) + val snapRect = clampPosition(drag.stationaryDisps.map { it.second }, dispDragRect) + + drag.display.place(topology.scaling.displayToPaneCoor(PointF(snapRect.left, snapRect.top))) + + return true + } + + private fun onBlockTouchUp(): Boolean { + val drag = mDrag ?: return false + val topology = mTopologyInfo ?: return false + mPaneContent.requestDisallowInterceptTouchEvent(false) + + val newCoor = topology.scaling.paneToDisplayCoor( + Point(drag.display.unpaddedX, drag.display.unpaddedY)) + val newTopology = topology.topology.copy() + val newPositions = drag.stationaryDisps.map { (id, pos) -> id to PointF(pos.left, pos.top) } + .plus(drag.displayId to newCoor) + + val arr = hashMapOf(*newPositions.toTypedArray()) + newTopology.rearrange(arr) + injector.displayTopology = newTopology + + refreshPane() + return true } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt index ad633cc8c8a..33cdb3eb92d 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt +++ b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt @@ -16,6 +16,7 @@ package com.android.settings.connecteddevice.display +import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT import android.content.Context @@ -23,10 +24,12 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.ColorDrawable import android.hardware.display.DisplayTopology +import android.view.MotionEvent import android.view.View import android.widget.FrameLayout import androidx.preference.PreferenceViewHolder import androidx.test.core.app.ApplicationProvider +import androidx.test.core.view.MotionEventBuilder import com.android.settings.R import com.google.common.truth.Truth.assertThat @@ -39,7 +42,7 @@ import org.robolectric.RobolectricTestRunner class DisplayTopologyPreferenceTest { val context = ApplicationProvider.getApplicationContext() val preference = DisplayTopologyPreference(context) - val injector = TestInjector() + val injector = TestInjector(context) val rootView = View.inflate(context, preference.layoutResource, /*parent=*/ null) val holder = PreferenceViewHolder.createInstanceForTests(rootView) val wallpaper = ColorDrawable(Color.MAGENTA) @@ -50,13 +53,16 @@ class DisplayTopologyPreferenceTest { preference.onBindViewHolder(holder) } - class TestInjector : DisplayTopologyPreference.Injector() { + class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) { var topology : DisplayTopology? = null var systemWallpaper : Drawable? = null - override fun displayTopology(context : Context) : DisplayTopology? { return topology } + override var displayTopology : DisplayTopology? + get() = topology + set(value) { topology = value } - override fun wallpaper(context : Context) : Drawable { return systemWallpaper!! } + override val wallpaper : Drawable + get() = systemWallpaper!! } @Test @@ -70,8 +76,16 @@ class DisplayTopologyPreferenceTest { assertThat(preference.mTopologyHint.text).isEqualTo("") } - @Test - fun twoDisplaysGenerateBlocks() { + private fun getPaneChildren(): List = + (0..preference.mPaneContent.childCount-1) + .map { preference.mPaneContent.getChildAt(it) as DisplayBlock } + .toList() + + /** + * Sets up a simple topology in the pane with two displays. Returns the left-hand display and + * right-hand display in order in a list. The right-hand display is the root. + */ + fun setupTwoDisplays(): List { val child = DisplayTopology.TreeNode( /* displayId= */ 42, /* width= */ 100f, /* height= */ 80f, POSITION_LEFT, /* offset= */ 42f) @@ -93,15 +107,19 @@ class DisplayTopologyPreferenceTest { preference.onAttached() preference.onGlobalLayout() - assertThat(preference.mPaneContent.childCount).isEqualTo(2) - val block0 = preference.mPaneContent.getChildAt(0) - val block1 = preference.mPaneContent.getChildAt(1) + val paneChildren = getPaneChildren() + assertThat(paneChildren).hasSize(2) // Block of child display is on the left. - val (childBlock, rootBlock) = if (block0.x < block1.x) - listOf(block0, block1) + return if (paneChildren[0].x < paneChildren[1].x) + paneChildren else - listOf(block1, block0) + paneChildren.reversed() + } + + @Test + fun twoDisplaysGenerateBlocks() { + val (childBlock, rootBlock) = setupTwoDisplays() // After accounting for padding, child should be half the length of root in each dimension. assertThat(childBlock.layoutParams.width + BLOCK_PADDING) @@ -109,12 +127,96 @@ class DisplayTopologyPreferenceTest { assertThat(childBlock.layoutParams.height + BLOCK_PADDING) .isEqualTo(rootBlock.layoutParams.height / 2) assertThat(childBlock.y).isGreaterThan(rootBlock.y) - assertThat(block0.background).isEqualTo(wallpaper) - assertThat(block1.background).isEqualTo(wallpaper) + assertThat(childBlock.background).isEqualTo(wallpaper) + assertThat(rootBlock.background).isEqualTo(wallpaper) assertThat(rootBlock.x - BLOCK_PADDING * 2) .isEqualTo(childBlock.x + childBlock.layoutParams.width) assertThat(preference.mTopologyHint.text) .isEqualTo(context.getString(R.string.external_display_topology_hint)) } + + @Test + fun dragDisplayDownward() { + val (leftBlock, rightBlock) = setupTwoDisplays() + + val downEvent = MotionEventBuilder.newBuilder() + .setPointer(0f, 0f) + .setAction(MotionEvent.ACTION_DOWN) + .build() + + // Move the left block half of its height downward. This is 40 pixels in display + // coordinates. The original offset is 42, so the new offset will be 42 + 40. + val moveEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_MOVE) + .setPointer(0f, leftBlock.layoutParams.height / 2f + BLOCK_PADDING) + .build() + val upEvent = MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build() + + leftBlock.dispatchTouchEvent(downEvent) + leftBlock.dispatchTouchEvent(moveEvent) + leftBlock.dispatchTouchEvent(upEvent) + + val rootChildren = injector.topology!!.root!!.children + assertThat(rootChildren).hasSize(1) + val child = rootChildren[0] + assertThat(child.position).isEqualTo(POSITION_LEFT) + assertThat(child.offset).isWithin(1f).of(82f) + } + + @Test + fun dragRootDisplayToNewSide() { + val (leftBlock, rightBlock) = setupTwoDisplays() + + val downEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_DOWN) + .setPointer(0f, 0f) + .build() + + // Move the right block left and upward. We won't move it into exactly the correct position, + // relying on the clamp algorithm to choose the correct side and offset. + val moveEvent = MotionEventBuilder.newBuilder() + .setAction(MotionEvent.ACTION_MOVE) + .setPointer( + -leftBlock.layoutParams.width - 2f * BLOCK_PADDING, + -leftBlock.layoutParams.height / 2f) + .build() + + val upEvent = MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build() + + assertThat(leftBlock.y).isGreaterThan(rightBlock.y) + + rightBlock.dispatchTouchEvent(downEvent) + rightBlock.dispatchTouchEvent(moveEvent) + rightBlock.dispatchTouchEvent(upEvent) + + val rootChildren = injector.topology!!.root!!.children + assertThat(rootChildren).hasSize(1) + val child = rootChildren[0] + assertThat(child.position).isEqualTo(POSITION_BOTTOM) + assertThat(child.offset).isWithin(1f).of(0f) + + // After rearranging blocks, the original block views should still be present. + val paneChildren = getPaneChildren() + assertThat(paneChildren.indexOf(leftBlock)).isNotEqualTo(-1) + assertThat(paneChildren.indexOf(rightBlock)).isNotEqualTo(-1) + + // Left edge of both blocks should be aligned after dragging. + assertThat(paneChildren[0].x) + .isWithin(1f) + .of(paneChildren[1].x) + } + + @Test + fun keepOriginalViewsWhenAddingMore() { + setupTwoDisplays() + val childrenBefore = getPaneChildren() + injector.topology!!.addDisplay(/* displayId= */ 101, 320f, 240f) + preference.refreshPane() + val childrenAfter = getPaneChildren() + + assertThat(childrenBefore).hasSize(2) + assertThat(childrenAfter).hasSize(3) + assertThat(childrenAfter.subList(0, 2)).isEqualTo(childrenBefore) + } }