Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b42336a4 authored by Matthew DeVore's avatar Matthew DeVore Committed by Android (Google) Code Review
Browse files

Merge "Add mirroring display to the topology pane stacked diagonally" into main

parents 49055317 e0924535
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -133,6 +133,7 @@ open class ConnectedDisplayInjector(open val context: Context?) {
    }

    /**
     * TODO(b/419742776): Unify this with #getAllDisplayIds
     * @return all displays including disabled.
     */
    open fun getConnectedDisplays(): List<DisplayDevice> {
@@ -152,6 +153,17 @@ open class ConnectedDisplayInjector(open val context: Context?) {
            .toList()
    }

    /**
     * This method return all enabled display ids without further filtering
     * TODO(b/419742776): Unify this with #getConnectedDisplays
     *
     * @see getConnectedDisplays to specifically fetch all connected displays
     */
    open fun getAllDisplayIds(): List<Int> {
        val dm = displayManager ?: return emptyList()
        return dm.getDisplays().map { it.displayId }.toList()
    }

    /**
     * @param displayId which must be returned
     * @return display object for the displayId, or null if display is not a connected display,
+13 −8
Original line number Diff line number Diff line
@@ -38,14 +38,13 @@ class DisplayBlock(val injector: ConnectedDisplayInjector) : FrameLayout(injecto
        context.resources.getDimensionPixelSize(R.dimen.display_block_padding)
    private val paneBgColor = context.resources.getColor(R.color.display_topology_background_color)

    private var _displayId: Int? = null
    // This doesn't necessarily refer to the actual display this block represents. In case of
    // mirroring, it will be the id of the mirrored display
    private var displayIdToShowWallpaper: Int? = null

    /** Scale of the mirrored wallpaper to the actual wallpaper size. */
    private var surfaceScale: Float? = null

    val displayId: Int?
        get() = _displayId

    // These are surfaces which must be removed from the display block hierarchy and released once
    // the new surface is put in place. This list can have more than one item because we may get
    // two reset calls before we get a single surfaceChange callback.
@@ -56,7 +55,7 @@ class DisplayBlock(val injector: ConnectedDisplayInjector) : FrameLayout(injecto

    @VisibleForTesting
    fun updateSurfaceView() {
        val displayId = _displayId ?: return
        val displayId = displayIdToShowWallpaper ?: return

        if (parent == null) {
            Log.i(TAG, "View for display $displayId has no parent - cancelling update")
@@ -148,21 +147,27 @@ class DisplayBlock(val injector: ConnectedDisplayInjector) : FrameLayout(injecto
    /**
     * Sets position and size of the block given coordinates in pane space.
     *
     * @param displayId ID of display this block represents, needed for fetching wallpaper
     * @param displayIdToShowWallpaper ID of the display whose wallpaper would be projected on this
     *  display block.
     * @param topLeft coordinates of top left corner of the block, not including highlight border
     * @param bottomRight coordinates of bottom right corner of the block, not including highlight
     *   border
     * @param surfaceScale scale in pixels of the size of the wallpaper mirror to the actual
     *   wallpaper on the screen - should be less than one to indicate scaling to smaller size
     */
    fun reset(displayId: Int, topLeft: PointF, bottomRight: PointF, surfaceScale: Float) {
    fun reset(
        displayIdToShowWallpaper: Int,
        topLeft: PointF,
        bottomRight: PointF,
        surfaceScale: Float,
    ) {
        wallpaperSurface?.let { oldSurfaces.add(it) }
        injector.handler.removeCallbacks(updateSurfaceView)
        wallpaperSurface = null
        setHighlighted(false)
        positionInPane = topLeft

        _displayId = displayId
        this.displayIdToShowWallpaper = displayIdToShowWallpaper
        this.surfaceScale = surfaceScale

        val newWidth = (bottomRight.x - topLeft.x).toInt()
+117 −33
Original line number Diff line number Diff line
@@ -19,8 +19,10 @@ package com.android.settings.connecteddevice.display
import android.graphics.PointF
import android.graphics.RectF
import android.hardware.display.DisplayTopology
import android.hardware.display.DisplayTopology.TreeNode
import android.util.Log
import android.util.Size
import android.view.Display.DEFAULT_DISPLAY
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
@@ -202,15 +204,34 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :

    @VisibleForTesting var timesRefreshedBlocks = 0

    /**
     * Updating DisplayTopology pane consists of multiple steps:
     * 1. Update hint text
     * 2. Prepare display blocks positioning
     * 3. Adjust display blocks bounds and scale within the pane
     * 4. Ensure wallpapers are revealed
     */
    private fun applyTopology(topology: DisplayTopology) {
        topologyHint.text = context.getString(R.string.external_display_topology_hint)

        val oldBounds = topologyInfo?.positions
        val newBounds = buildList {
            val bounds = topology.absoluteBounds
            (0..bounds.size() - 1).forEach { add(Pair(bounds.keyAt(it), bounds.valueAt(it))) }
        // Step 1
        val showStackedMirroringDisplay =
            isDisplayInMiroringMode(context) &&
                injector.flags.showStackedMirroringDisplayConnectedDisplaySetting()
        topologyHint.text =
            if (showStackedMirroringDisplay) {
                ""
            } else {
                context.getString(R.string.external_display_topology_hint)
            }

        val idToNode = topology.allNodesIdMap()
        val logicalDisplaySizeFetcher = LogicalDisplaySizeFetcher(injector, idToNode)

        // Step 2
        val oldBounds = topologyInfo?.positions
        val newBounds =
            if (showStackedMirroringDisplay)
                processDisplayBoundsMirroringMode(logicalDisplaySizeFetcher)
            else processDisplayBounds(topology)
        if (
            oldBounds != null &&
                oldBounds.size == newBounds.size &&
@@ -221,17 +242,7 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
            return
        }

        val recycleableBlocks = ArrayDeque<DisplayBlock>()
        for (i in 0..paneContent.childCount - 1) {
            recycleableBlocks.add(paneContent.getChildAt(i) as DisplayBlock)
        }

        val idToNode = topology.allNodesIdMap()
        val topologyLogicalDisplaySize =
            idToNode
                .filter { it.key != null && it.value != null }
                .map { it.key!! to Size(it.value.logicalWidth, it.value.logicalHeight) }
                .toMap()
        // Step 3
        val scaling =
            TopologyScale(
                paneContent.width,
@@ -239,15 +250,26 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
                maxEdgeLength = DisplayTopology.dpToPx(MAX_EDGE_LENGTH_DP, injector.densityDpi),
                newBounds.map { it.second },
            )
        setupDisplayPaneAndBlocks(scaling, newBounds, topologyLogicalDisplaySize)
        setupDisplayPaneAndBlocks(
            scaling,
            newBounds,
            logicalDisplaySizeFetcher,
            showStackedMirroringDisplay,
        )
        topologyInfo = TopologyInfo(topology, scaling, newBounds)

        // Step 4
        val displayIdsToRevealWallpaper =
            if (showStackedMirroringDisplay) setOf(DEFAULT_DISPLAY)
            else {
                idToNode.keys.toSet()
            }
        // Construct a map containing revealers that we want to keep (keepRevealing). Then create a
        // list comprised of the values of that map as well as new revealers (revealedWallpapers).
        val keepRevealing =
            buildMap<Int, RevealedWallpaper> {
                revealedWallpapers.forEach { r ->
                    if (idToNode.containsKey(r.displayId)) {
                    if (displayIdsToRevealWallpaper.contains(r.displayId)) {
                        put(r.displayId, r)
                    } else {
                        r.viewManager.removeView(r.revealer)
@@ -255,16 +277,49 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
                }
            }
        revealedWallpapers =
            idToNode.keys
            displayIdsToRevealWallpaper
                .map { keepRevealing.get(it) ?: injector.revealWallpaper(it) }
                .filterNotNull()
                .toList()
    }

    private fun processDisplayBounds(topology: DisplayTopology) = buildList {
        val bounds = topology.absoluteBounds
        (0..bounds.size() - 1).forEach { add(Pair(bounds.keyAt(it), bounds.valueAt(it))) }
    }

    private fun processDisplayBoundsMirroringMode(
        logicalDisplaySizeFetcher: LogicalDisplaySizeFetcher
    ): List<Pair<Int, RectF>> {
        val displayIds = injector.getAllDisplayIds().sortedBy { it }

        val bounds = mutableListOf<Pair<Int, RectF>>()
        val mirroringDiagonalStackOffsetPx =
            DisplayTopology.dpToPx(MIRRORING_DIAGONAL_STACK_OFFSET_DP, injector.densityDpi)

        // Displays are arranged 45 degrees diagonally, with DEFAULT_DISPLAY on the front and
        // leftmost, and other displays on the back, top-right of the display on the front.
        for (i in 0..displayIds.size - 1) {
            val displayId = displayIds[i]
            val offsetPx = mirroringDiagonalStackOffsetPx * i
            logicalDisplaySizeFetcher.get(displayId)?.let {
                bounds.add(
                    Pair(
                        displayId,
                        RectF(offsetPx, -offsetPx, it.width + offsetPx, it.height - offsetPx),
                    )
                )
            }
        }
        // Reverse the z-order to make the first added display (DEFAULT_DISPLAY) on the front.
        return bounds.reversed()
    }

    private fun setupDisplayPaneAndBlocks(
        scaling: TopologyScale,
        newBounds: List<Pair<Int, RectF>>,
        topologyLogicalDisplaySize: Map<Int, Size>,
        logicalDisplaySizeFetcher: LogicalDisplaySizeFetcher,
        isMirroring: Boolean,
    ) {
        // Resize pane holder
        paneHolder.layoutParams.let {
@@ -284,15 +339,20 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
            val block =
                recycleableBlocks.removeFirstOrNull()
                    ?: DisplayBlock(injector).apply { paneContent.addView(this) }
            // First check from DisplayTopology for quick lookup on logical display size. If display
            // is not in topology, then query from DisplayInfo.
            val logicalDisplaySize =
                topologyLogicalDisplaySize.get(id) ?: injector.getLogicalSize(id)
            logicalDisplaySize?.let {
            logicalDisplaySizeFetcher.get(id)?.let {
                val topLeft = scaling.displayToPaneCoor(pos.left, pos.top)
                val bottomRight = scaling.displayToPaneCoor(pos.right, pos.bottom)
                block.reset(id, topLeft, bottomRight, (bottomRight.x - topLeft.x) / it.width)
                block.reset(
                    // Mirroring is only supported for DEFAULT_DISPLAY for now
                    if (isMirroring) DEFAULT_DISPLAY else id,
                    topLeft,
                    bottomRight,
                    (bottomRight.x - topLeft.x) / it.width,
                )
            }
            if (isMirroring) {
                block.setOnTouchListener(null)
            } else {
                block.setOnTouchListener { view, ev ->
                    when (ev.actionMasked) {
                        MotionEvent.ACTION_DOWN -> onBlockTouchDown(id, pos, block, ev)
@@ -302,6 +362,7 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
                    }
                }
            }
        }
        paneContent.removeViews(newBounds.size, recycleableBlocks.size)
        timesRefreshedBlocks++
        // Cancel the drag if one is in progress.
@@ -412,9 +473,32 @@ class DisplayTopologyPreference(val injector: ConnectedDisplayInjector) :
        return true
    }

    /**
     * A simple wrapper class to fetch logical display size from either DisplayTopology or directly
     * from DisplayManager. This should used as a temporary variable only for the current
     * DisplayTopology update.
     */
    private class LogicalDisplaySizeFetcher(
        val injector: ConnectedDisplayInjector,
        idToNode: Map<Int?, TreeNode?>,
    ) {
        private val topologyLogicalDisplaySize =
            idToNode
                .filter { it.key != null && it.value != null }
                .map { it.key!! to Size(it.value!!.logicalWidth, it.value!!.logicalHeight) }
                .toMap()

        fun get(id: Int): Size? {
            // First check from DisplayTopology for quick lookup on logical display size. If display
            // is not in topology, then query from DisplayInfo.
            return topologyLogicalDisplaySize.get(id) ?: injector.getLogicalSize(id)
        }
    }

    private companion object {
        private val MIN_EDGE_LENGTH_DP = 60f
        private val MAX_EDGE_LENGTH_DP = 256f
        private val MIRRORING_DIAGONAL_STACK_OFFSET_DP = 120f
        private val TAG = "DisplayTopologyPreference"
    }
}
+86 −2
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import android.hardware.display.DisplayTopology
import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM
import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT
import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP
import android.provider.Settings
import android.util.DisplayMetrics
import android.util.Size
import android.view.MotionEvent
@@ -33,6 +34,8 @@ import androidx.preference.PreferenceViewHolder
import androidx.test.core.app.ApplicationProvider
import androidx.test.core.view.MotionEventBuilder
import com.android.settings.R
import com.android.settings.flags.FakeFeatureFlagsImpl
import com.android.settings.flags.Flags.FLAG_SHOW_STACKED_MIRRORING_DISPLAY_CONNECTED_DISPLAY_SETTING
import com.google.common.truth.Truth.assertThat
import java.util.function.Consumer
import kotlin.math.abs
@@ -43,16 +46,20 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DisplayTopologyPreferenceTest {
    val context = ApplicationProvider.getApplicationContext<Context>()
    val injector = TestInjector(context)
    val featureFlags = FakeFeatureFlagsImpl()
    val injector = TestInjector(context, featureFlags)
    val preference = DisplayTopologyPreference(injector)
    val rootView = View.inflate(context, preference.layoutResource, /* parent= */ null)
    val holder = PreferenceViewHolder.createInstanceForTests(rootView)

    init {
        preference.onBindViewHolder(holder)

        featureFlags.setFlag(FLAG_SHOW_STACKED_MIRRORING_DISPLAY_CONNECTED_DISPLAY_SETTING, true)
    }

    class TestInjector(context: Context) : ConnectedDisplayInjector(context) {
    class TestInjector(context: Context, featureFlags: FakeFeatureFlagsImpl) :
        ConnectedDisplayInjector(context) {
        var displaysSize = mutableMapOf<Int, Size>()
        var topology: DisplayTopology? = null

@@ -64,6 +71,8 @@ class DisplayTopologyPreferenceTest {
                topology = value
            }

        override val flags = DesktopExperienceFlags(featureFlags)

        /** A log of events related to wallpaper revealing. */
        val revealLog = mutableListOf<String>()

@@ -73,6 +82,10 @@ class DisplayTopologyPreferenceTest {
            return displaysSize.get(displayId)
        }

        override fun getAllDisplayIds(): List<Int> {
            return displaysSize.keys.toList()
        }

        override fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
            if (topologyListener != null) {
                throw IllegalStateException(
@@ -228,6 +241,14 @@ class DisplayTopologyPreferenceTest {
        assertThat(block.selectionMarkerView.visibility).isEqualTo(vis)
    }

    fun setMirroringMode(enable: Boolean) {
        Settings.Secure.putInt(
            context.contentResolver,
            Settings.Secure.MIRROR_BUILT_IN_DISPLAY,
            if (enable) 1 else 0,
        )
    }

    @Test
    fun twoDisplaysGenerateBlocks() {
        val (childBlock, rootBlock) = setupPaneWithTwoDisplays()
@@ -246,6 +267,36 @@ class DisplayTopologyPreferenceTest {
            .isEqualTo(context.getString(R.string.external_display_topology_hint))
    }

    @Test
    fun twoDisplaysMirroringGenerateBlocks() {
        setMirroringMode(true)
        setupPaneWithTwoDisplays()
        val newDisplayId = 123
        val newDisplaySize = Size(500, 500)
        injector.topology!!.addDisplay(
            newDisplayId,
            newDisplaySize.width,
            newDisplaySize.height,
            /* logicalDensity= */ 160,
        )
        injector.displaysSize.put(newDisplayId, newDisplaySize)

        val paneChildren = getPaneChildren()
        assertThat(paneChildren).hasSize(2)

        for (i in 1..paneChildren.size - 1) {
            // Bounds are arranged 45 degrees diagonally from the top left corner, in a decreasing
            // X and increasing Y, since the backmost display will be the first on the list.
            val bounds = virtualBounds(paneChildren[i])
            val prevBounds = virtualBounds(paneChildren[i - 1])
            assertThat(bounds.left).isLessThan(prevBounds.left)
            assertThat(bounds.top).isGreaterThan(prevBounds.top)
            assertSelected(paneChildren[i], false)
        }

        assertThat(preference.topologyHint.text).isEqualTo("")
    }

    @Test
    fun dragDisplayDownward() {
        val (leftBlock, _) = setupPaneWithTwoDisplays()
@@ -480,6 +531,39 @@ class DisplayTopologyPreferenceTest {
        assertThat(block.y).isWithin(0.01f).of(origY)
    }

    @Test
    fun cannotMoveDisplayMirroringMode() {
        setMirroringMode(true)
        setupPaneWithTwoDisplays()

        val paneChildren = getPaneChildren()
        assertThat(paneChildren).hasSize(2)
        for (block in paneChildren) {
            val origY = block.y

            block.dispatchTouchEvent(
                MotionEventBuilder.newBuilder()
                    .setAction(MotionEvent.ACTION_DOWN)
                    .setPointer(0f, 0f)
                    .build()
            )
            assertSelected(block, false)

            block.dispatchTouchEvent(
                MotionEventBuilder.newBuilder()
                    .setAction(MotionEvent.ACTION_MOVE)
                    .setPointer(0f, 30f)
                    .build()
            )
            assertThat(block.y).isWithin(0.01f).of(origY)

            block.dispatchTouchEvent(
                MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build()
            )
            assertThat(block.y).isWithin(0.01f).of(origY)
        }
    }

    @Test
    fun updatedTopologyCancelsDragIfNonTrivialChange() {
        val (leftBlock, _) = setupPaneWithTwoDisplays(POSITION_LEFT, /* childOffset= */ 42f)