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

Commit b51d99a7 authored by Joshua Tsuji's avatar Joshua Tsuji
Browse files

Adds the FloatingContentCoordinator.

This class helps PIP, Bubbles, and future additions to the floating family peacefully co-exist.

Design doc: go/pip-bubbles-unification#heading=h.ip7f8yd5i199

This is the initial CL - a followup will add the permanent/temporary move logic, which restores floating content's original positions if a move is aborted.

Bug: 138115889
Test: atest SystemUITests
Change-Id: I762ce25700523ff581e1dc827de3bb9128ea97a2
parent 3d358dba
Loading
Loading
Loading
Loading
+320 −0
Original line number Diff line number Diff line
package com.android.systemui.util

import android.graphics.Rect
import android.util.Log
import com.android.systemui.util.FloatingContentCoordinator.FloatingContent
import java.util.HashMap
import javax.inject.Inject
import javax.inject.Singleton

/** Tag for debug logging. */
private const val TAG = "FloatingCoordinator"

/**
 * Coordinates the positions and movement of floating content, such as PIP and Bubbles, to ensure
 * that they don't overlap. If content does overlap due to content appearing or moving, the
 * coordinator will ask content to move to resolve the conflict.
 *
 * After implementing [FloatingContent], content should call [onContentAdded] to begin coordination.
 * Subsequently, call [onContentMoved] whenever the content moves, and the coordinator will move
 * other content out of the way. [onContentRemoved] should be called when the content is removed or
 * no longer visible.
 */
@Singleton
class FloatingContentCoordinator @Inject constructor() {

    /**
     * Represents a piece of floating content, such as PIP or the Bubbles stack. Provides methods
     * that allow the [FloatingContentCoordinator] to determine the current location of the content,
     * as well as the ability to ask it to move out of the way of other content.
     *
     * The default implementation of [calculateNewBoundsOnOverlap] moves the content up or down,
     * depending on the position of the conflicting content. You can override this method if you
     * want your own custom conflict resolution logic.
     */
    interface FloatingContent {

        /**
         * Return the bounds claimed by this content. This should include the bounds occupied by the
         * content itself, as well as any padding, if desired. The coordinator will ensure that no
         * other content is located within these bounds.
         *
         * If the content is animating, this method should return the bounds to which the content is
         * animating. If that animation is cancelled, or updated, be sure that your implementation
         * of this method returns the appropriate bounds, and call [onContentMoved] so that the
         * coordinator moves other content out of the way.
         */
        fun getFloatingBoundsOnScreen(): Rect

        /**
         * Return the area within which this floating content is allowed to move. When resolving
         * conflicts, the coordinator will never ask your content to move to a position where any
         * part of the content would be out of these bounds.
         */
        fun getAllowedFloatingBoundsRegion(): Rect

        /**
         * Called when the coordinator needs this content to move to the given bounds. It's up to
         * you how to do that.
         *
         * Note that if you start an animation to these bounds, [getFloatingBoundsOnScreen] should
         * return the destination bounds, not the in-progress animated bounds. This is so the
         * coordinator knows where floating content is going to be and can resolve conflicts
         * accordingly.
         */
        fun moveToBounds(bounds: Rect)

        /**
         * Called by the coordinator when it needs to find a new home for this floating content,
         * because a new or moving piece of content is now overlapping with it.
         *
         * [findAreaForContentVertically] and [findAreaForContentAboveOrBelow] are helpful utility
         * functions that will find new bounds for your content automatically. Unless you require
         * specific conflict resolution logic, these should be sufficient. By default, this method
         * delegates to [findAreaForContentVertically].
         *
         * @param overlappingContentBounds The bounds of the other piece of content, which
         * necessitated this content's relocation. Your new position must not overlap with these
         * bounds.
         * @param otherContentBounds The bounds of any other pieces of floating content. Your new
         * position must not overlap with any of these either. These bounds are guaranteed to be
         * non-overlapping.
         * @return The new bounds for this content.
         */
        @JvmDefault
        fun calculateNewBoundsOnOverlap(
            overlappingContentBounds: Rect,
            otherContentBounds: List<Rect>
        ): Rect {
            return findAreaForContentVertically(
                    getFloatingBoundsOnScreen(),
                    overlappingContentBounds,
                    otherContentBounds,
                    getAllowedFloatingBoundsRegion())
        }
    }

    /** The bounds of all pieces of floating content added to the coordinator. */
    private val allContentBounds: MutableMap<FloatingContent, Rect> = HashMap()

    /**
     * Makes the coordinator aware of a new piece of floating content, and moves any existing
     * content out of the way, if necessary.
     *
     * If you don't want your new content to move existing content, use [getOccupiedBounds] to find
     * an unoccupied area, and move the content there before calling this method.
     */
    fun onContentAdded(newContent: FloatingContent) {
        updateContentBounds()
        allContentBounds[newContent] = newContent.getFloatingBoundsOnScreen()
        maybeMoveConflictingContent(newContent)
    }

    /**
     * Called to notify the coordinator that a piece of floating content has moved (or is animating)
     * to a new position, and that any conflicting floating content should be moved out of the way.
     *
     * The coordinator will call [FloatingContent.getFloatingBoundsOnScreen] to find the new bounds
     * for the moving content. If you're animating the content, be sure that your implementation of
     * getFloatingBoundsOnScreen returns the bounds to which it's animating, not the content's
     * current bounds.
     *
     * If the animation moving this content is cancelled or updated, you'll need to call this method
     * again, to ensure that content is moved out of the way of the latest bounds.
     *
     * @param content The content that has moved.
     */
    @JvmOverloads
    fun onContentMoved(content: FloatingContent) {
        if (!allContentBounds.containsKey(content)) {
            Log.wtf(TAG, "Received onContentMoved call before onContentAdded! " +
                    "This should never happen.")
            return
        }

        updateContentBounds()
        maybeMoveConflictingContent(content)
    }

    /**
     * Called to notify the coordinator that a piece of floating content has been removed or is no
     * longer visible.
     */
    fun onContentRemoved(removedContent: FloatingContent) {
        allContentBounds.remove(removedContent)
    }

    /**
     * Returns a set of Rects that represent the bounds of all of the floating content on the
     * screen.
     *
     * [onContentAdded] will move existing content out of the way if the added content intersects
     * existing content. That's fine - but if your specific starting position is not important, you
     * can use this function to find unoccupied space for your content before calling
     * [onContentAdded], so that moving existing content isn't necessary.
     */
    fun getOccupiedBounds(): Collection<Rect> {
        return allContentBounds.values
    }

    /**
     * Identifies any pieces of content that are now overlapping with the given content, and asks
     * them to move out of the way.
     */
    private fun maybeMoveConflictingContent(fromContent: FloatingContent) {
        val conflictingNewBounds = allContentBounds[fromContent]!!
        allContentBounds
                // Filter to content that intersects with the new bounds. That's content that needs
                // to move.
                .filter { (content, bounds) ->
                    content != fromContent && Rect.intersects(conflictingNewBounds, bounds) }
                // Tell that content to get out of the way, and save the bounds it says it's moving
                // (or animating) to.
                .forEach { (content, bounds) ->
                    content.moveToBounds(
                            content.calculateNewBoundsOnOverlap(
                                    conflictingNewBounds,
                                    // Pass all of the content bounds except the bounds of the
                                    // content we're asking to move, and the conflicting new bounds
                                    // (since those are passed separately).
                                    otherContentBounds = allContentBounds.values
                                            .minus(bounds)
                                            .minus(conflictingNewBounds)))
                    allContentBounds[content] = content.getFloatingBoundsOnScreen()
                }
    }

    /**
     * Update [allContentBounds] by calling [FloatingContent.getFloatingBoundsOnScreen] for all
     * content and saving the result.
     */
    private fun updateContentBounds() {
        allContentBounds.keys.forEach { allContentBounds[it] = it.getFloatingBoundsOnScreen() }
    }

    companion object {
        /**
         * Finds new bounds for the given content, either above or below its current position. The
         * new bounds won't intersect with the newly overlapping rect or the exclusion rects, and
         * will be within the allowed bounds unless no possible position exists.
         *
         * You can use this method to help find a new position for your content when the coordinator
         * calls [FloatingContent.moveToAreaExcluding].
         *
         * @param contentRect The bounds of the content for which we're finding a new home.
         * @param newlyOverlappingRect The bounds of the content that forced this relocation by
         * intersecting with the content we now need to move. If the overlapping content is
         * overlapping the top half of this content, we'll try to move this content downward if
         * possible (since the other content is 'pushing' it down), and vice versa.
         * @param exclusionRects Any other areas that we need to avoid when finding a new home for
         * the content. These areas must be non-overlapping with each other.
         * @param allowedBounds The area within which we're allowed to find new bounds for the
         * content.
         * @return New bounds for the content that don't intersect the exclusion rects or the
         * newly overlapping rect, and that is within bounds unless no possible in-bounds position
         * exists.
         */
        @JvmStatic
        fun findAreaForContentVertically(
            contentRect: Rect,
            newlyOverlappingRect: Rect,
            exclusionRects: Collection<Rect>,
            allowedBounds: Rect
        ): Rect {
            // If the newly overlapping Rect's center is above the content's center, we'll prefer to
            // find a space for this content that is below the overlapping content, since it's
            // 'pushing' it down. This may not be possible due to to screen bounds, in which case
            // we'll find space in the other direction.
            val overlappingContentPushingDown =
                    newlyOverlappingRect.centerY() < contentRect.centerY()

            // Filter to exclusion rects that are above or below the content that we're finding a
            // place for. Then, split into two lists - rects above the content, and rects below it.
            var (rectsToAvoidAbove, rectsToAvoidBelow) = exclusionRects
                    .filter { rectToAvoid -> rectsIntersectVertically(rectToAvoid, contentRect) }
                    .partition { rectToAvoid -> rectToAvoid.top < contentRect.top }

            // Lazily calculate the closest possible new tops for the content, above and below its
            // current location.
            val newContentBoundsAbove by lazy { findAreaForContentAboveOrBelow(
                    contentRect,
                    exclusionRects = rectsToAvoidAbove.plus(newlyOverlappingRect),
                    findAbove = true) }
            val newContentBoundsBelow by lazy { findAreaForContentAboveOrBelow(
                    contentRect,
                    exclusionRects = rectsToAvoidBelow.plus(newlyOverlappingRect),
                    findAbove = false) }

            val positionAboveInBounds by lazy { allowedBounds.contains(newContentBoundsAbove) }
            val positionBelowInBounds by lazy { allowedBounds.contains(newContentBoundsBelow) }

            // Use the 'below' position if the content is being overlapped from the top, unless it's
            // out of bounds. Also use it if the content is being overlapped from the bottom, but
            // the 'above' position is out of bounds. Otherwise, use the 'above' position.
            val usePositionBelow =
                    overlappingContentPushingDown && positionBelowInBounds ||
                            !overlappingContentPushingDown && !positionAboveInBounds

            // Return the content rect, but offset to reflect the new position.
            return if (usePositionBelow) newContentBoundsBelow else newContentBoundsAbove
        }

        /**
         * Finds a new position for the given content, either above or below its current position
         * depending on whether [findAbove] is true or false, respectively. This new position will
         * not intersect with any of the [exclusionRects].
         *
         * This method is useful as a helper method for implementing your own conflict resolution
         * logic. Otherwise, you'd want to use [findAreaForContentVertically], which takes screen
         * bounds and conflicting bounds' location into account when deciding whether to move to new
         * bounds above or below the current bounds.
         *
         * @param contentRect The content we're finding an area for.
         * @param exclusionRects The areas we need to avoid when finding a new area for the content.
         * These areas must be non-overlapping with each other.
         * @param findAbove Whether we are finding an area above the content's current position,
         * rather than an area below it.
         */
        fun findAreaForContentAboveOrBelow(
            contentRect: Rect,
            exclusionRects: Collection<Rect>,
            findAbove: Boolean
        ): Rect {
            // Sort the rects, since we want to move the content as little as possible. We'll
            // start with the rects closest to the content and move outward. If we're finding an
            // area above the content, that means we sort in reverse order to search the rects
            // from highest to lowest y-value.
            val sortedExclusionRects =
                    exclusionRects.sortedBy { if (findAbove) -it.top else it.top }

            val proposedNewBounds = Rect(contentRect)
            for (exclusionRect in sortedExclusionRects) {
                // If the proposed new bounds don't intersect with this exclusion rect, that
                // means there's room for the content here. We know this because the rects are
                // sorted and non-overlapping, so any subsequent exclusion rects would be higher
                // (or lower) than this one and can't possibly intersect if this one doesn't.
                if (!Rect.intersects(proposedNewBounds, exclusionRect)) {
                    break
                } else {
                    // Otherwise, we need to keep searching for new bounds. If we're finding an
                    // area above, propose new bounds that place the content just above the
                    // exclusion rect. If we're finding an area below, propose new bounds that
                    // place the content just below the exclusion rect.
                    val verticalOffset =
                            if (findAbove) -contentRect.height() else exclusionRect.height()
                    proposedNewBounds.offsetTo(
                            proposedNewBounds.left,
                            exclusionRect.top + verticalOffset)
                }
            }

            return proposedNewBounds
        }

        /** Returns whether or not the two Rects share any of the same space on the X axis. */
        private fun rectsIntersectVertically(r1: Rect, r2: Rect): Boolean {
            return (r1.left >= r2.left && r1.left <= r2.right) ||
                    (r1.right <= r2.right && r1.right >= r2.left)
        }
    }
}
 No newline at end of file
+218 −0
Original line number Diff line number Diff line
package com.android.systemui.util

import android.graphics.Rect
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@TestableLooper.RunWithLooper
@RunWith(AndroidTestingRunner::class)
@SmallTest
class FloatingContentCoordinatorTest : SysuiTestCase() {

    private val screenBounds = Rect(0, 0, 1000, 1000)

    private val rect100px = Rect()
    private val rect100pxFloating = FloatingRect(rect100px)

    private val rect200px = Rect()
    private val rect200pxFloating = FloatingRect(rect200px)

    private val rect300px = Rect()
    private val rect300pxFloating = FloatingRect(rect300px)

    private val floatingCoordinator = FloatingContentCoordinator()

    @Before
    fun setup() {
        rect100px.set(0, 0, 100, 100)
        rect200px.set(0, 0, 200, 200)
        rect300px.set(0, 0, 300, 300)
    }

    @After
    fun tearDown() {
        // We need to remove this stuff since it's a singleton object and it'll be there for the
        // next test.
        floatingCoordinator.onContentRemoved(rect100pxFloating)
        floatingCoordinator.onContentRemoved(rect200pxFloating)
        floatingCoordinator.onContentRemoved(rect300pxFloating)
    }

    @Test
    fun testOnContentAdded() {
        // Add rect1, and verify that the coordinator didn't move it.
        floatingCoordinator.onContentAdded(rect100pxFloating)
        assertEquals(rect100px.top, 0)

        // Add rect2, which intersects rect1. Verify that rect2 was not moved, since newly added
        // content is allowed to remain where it is. rect1 should have been moved below rect2
        // since it was in the way.
        floatingCoordinator.onContentAdded(rect200pxFloating)
        assertEquals(rect200px.top, 0)
        assertEquals(rect100px.top, 200)

        verifyRectSizes()
    }

    @Test
    fun testOnContentRemoved() {
        // Add rect1, and remove it. Then add rect2. Since rect1 was removed before that, it should
        // no longer be considered in the way, so it shouldn't move when rect2 is added.
        floatingCoordinator.onContentAdded(rect100pxFloating)
        floatingCoordinator.onContentRemoved(rect100pxFloating)
        floatingCoordinator.onContentAdded(rect200pxFloating)

        assertEquals(rect100px.top, 0)
        assertEquals(rect200px.top, 0)

        verifyRectSizes()
    }

    @Test
    fun testOnContentMoved_twoRects() {
        // Add rect1, which is at y = 0.
        floatingCoordinator.onContentAdded(rect100pxFloating)

        // Move rect2 down to 500px, where it won't conflict with rect1.
        rect200px.offsetTo(0, 500)
        floatingCoordinator.onContentAdded(rect200pxFloating)

        // Then, move it to 0px where it will absolutely conflict with rect1.
        rect200px.offsetTo(0, 0)
        floatingCoordinator.onContentMoved(rect200pxFloating)

        // The coordinator should have left rect2 alone, and moved rect1 below it. rect1 should now
        // be at y = 200.
        assertEquals(rect200px.top, 0)
        assertEquals(rect100px.top, 200)

        verifyRectSizes()

        // Move rect2 to y = 275px. Since this puts it at the bottom half of rect1, it should push
        // rect1 upward and leave rect2 alone.
        rect200px.offsetTo(0, 275)
        floatingCoordinator.onContentMoved(rect200pxFloating)

        assertEquals(rect200px.top, 275)
        assertEquals(rect100px.top, 175)

        verifyRectSizes()

        // Move rect2 to y = 110px. This makes it intersect rect1 again, but above its center of
        // mass. That means rect1 should be pushed downward.
        rect200px.offsetTo(0, 110)
        floatingCoordinator.onContentMoved(rect200pxFloating)

        assertEquals(rect200px.top, 110)
        assertEquals(rect100px.top, 310)

        verifyRectSizes()
    }

    @Test
    fun testOnContentMoved_threeRects() {
        floatingCoordinator.onContentAdded(rect100pxFloating)

        // Add rect2, which should displace rect1 to y = 200
        floatingCoordinator.onContentAdded(rect200pxFloating)
        assertEquals(rect200px.top, 0)
        assertEquals(rect100px.top, 200)

        // Add rect3, which should completely cover both rect1 and rect2. That should cause them to
        // move away. The order in which they do so is non-deterministic, so just make sure none of
        // the three Rects intersect.
        floatingCoordinator.onContentAdded(rect300pxFloating)

        assertFalse(Rect.intersects(rect100px, rect200px))
        assertFalse(Rect.intersects(rect100px, rect300px))
        assertFalse(Rect.intersects(rect200px, rect300px))

        // Move rect2 to intersect both rect1 and rect3.
        rect200px.offsetTo(0, 150)
        floatingCoordinator.onContentMoved(rect200pxFloating)

        assertFalse(Rect.intersects(rect100px, rect200px))
        assertFalse(Rect.intersects(rect100px, rect300px))
        assertFalse(Rect.intersects(rect200px, rect300px))
    }

    @Test
    fun testOnContentMoved_respectsUpperBounds() {
        // Add rect1, which is at y = 0.
        floatingCoordinator.onContentAdded(rect100pxFloating)

        // Move rect2 down to 500px, where it won't conflict with rect1.
        rect200px.offsetTo(0, 500)
        floatingCoordinator.onContentAdded(rect200pxFloating)

        // Then, move it to 90px where it will conflict with rect1, but with a center of mass below
        // that of rect1's. This would normally mean that rect1 moves upward. However, since it's at
        // the top of the screen, it should go downward instead.
        rect200px.offsetTo(0, 90)
        floatingCoordinator.onContentMoved(rect200pxFloating)

        // rect2 should have been left alone, rect1 is now below rect2 at y = 290px even though it
        // was intersected from below.
        assertEquals(rect200px.top, 90)
        assertEquals(rect100px.top, 290)
    }

    @Test
    fun testOnContentMoved_respectsLowerBounds() {
        // Put rect1 at the bottom of the screen and add it.
        rect100px.offsetTo(0, screenBounds.bottom - 100)
        floatingCoordinator.onContentAdded(rect100pxFloating)

        // Put rect2 at the bottom as well. Since its center of mass is above rect1's, rect1 would
        // normally move downward. Since it's at the bottom of the screen, it should go upward
        // instead.
        rect200px.offsetTo(0, 800)
        floatingCoordinator.onContentAdded(rect200pxFloating)

        assertEquals(rect200px.top, 800)
        assertEquals(rect100px.top, 700)
    }

    /**
     * Tests that the rect sizes didn't change when the coordinator manipulated them. This allows us
     * to assert only the value of rect.top in tests, since if top, width, and height are correct,
     * that means top/left/right/bottom are all correct.
     */
    private fun verifyRectSizes() {
        assertEquals(100, rect100px.width())
        assertEquals(200, rect200px.width())
        assertEquals(300, rect300px.width())

        assertEquals(100, rect100px.height())
        assertEquals(200, rect200px.height())
        assertEquals(300, rect300px.height())
    }

    /**
     * Helper class that uses [floatingCoordinator.findAreaForContentVertically] to move a
     * Rect when needed.
     */
    inner class FloatingRect(
        private val underlyingRect: Rect
    ) : FloatingContentCoordinator.FloatingContent {
        override fun moveToBounds(bounds: Rect) {
            underlyingRect.set(bounds)
        }

        override fun getAllowedFloatingBoundsRegion(): Rect {
            return screenBounds
        }

        override fun getFloatingBoundsOnScreen(): Rect {
            return underlyingRect
        }
    }
}
 No newline at end of file