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

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

Merge changes Ib57bca55,I7ceb2964 into main

* changes:
  Topology Pane: narrow padding between blocks
  connected display: merge injector classes into one
parents 9d9a5598 cd218c49
Loading
Loading
Loading
Loading
+39 −0
Original line number Diff line number Diff line
@@ -15,9 +15,25 @@
  ~
  -->

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- A non-rounded rectangle is needed to prevent the enclosed image from
         leaking out behind the rounded corners. -->
    <item>
        <shape android:shape="rectangle">
            <stroke
                android:color="@color/display_topology_background_color"
                android:width="@dimen/display_block_padding" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <stroke
                android:color="@color/display_topology_background_color"
                android:width="@dimen/display_block_padding" />
            <corners android:radius="@dimen/display_block_corner_radius" />
        </shape>
    </item>

</layer-list>
+6 −22
Original line number Diff line number Diff line
@@ -15,25 +15,9 @@
  ~
  -->

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Inner border -->
    <item>
        <shape android:shape="rectangle">
            <stroke
                android:color="@color/display_topology_background_color"
                android:width="@dimen/display_block_padding" />
            <corners android:radius="@dimen/display_block_corner_radius" />
        </shape>
    </item>

    <!-- Outer border -->
    <item>
        <shape android:shape="rectangle">
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <stroke
        android:color="@color/system_secondary"
        android:width="@dimen/display_block_highlight_width" />
    <corners android:radius="@dimen/display_block_corner_radius" />
</shape>
    </item>
</layer-list>
+233 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.settings.connecteddevice.display

import android.app.WallpaperManager
import android.content.Context
import android.content.Context.DISPLAY_SERVICE
import android.graphics.Bitmap
import android.hardware.display.DisplayManager
import android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED
import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED
import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED
import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED
import android.hardware.display.DisplayManager.PRIVATE_EVENT_TYPE_DISPLAY_CONNECTION_CHANGED
import android.hardware.display.DisplayManagerGlobal
import android.hardware.display.DisplayTopology
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
import android.os.SystemProperties
import android.util.DisplayMetrics
import android.util.Log
import android.view.Display
import android.view.Display.INVALID_DISPLAY
import android.view.DisplayInfo
import android.view.IWindowManager
import android.view.WindowManagerGlobal

import com.android.server.display.feature.flags.Flags.enableModeLimitForExternalDisplay
import com.android.settings.connecteddevice.display.ExternalDisplaySettingsConfiguration.VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY
import com.android.settings.flags.FeatureFlags
import com.android.settings.flags.FeatureFlagsImpl

import java.util.function.Consumer

open class ConnectedDisplayInjector(open val context: Context?) {

    open val flags: FeatureFlags by lazy { DesktopExperienceFlags(FeatureFlagsImpl()) }
    open val handler: Handler by lazy { Handler(Looper.getMainLooper()) }

    /**
     * @param name of a system property.
     * @return the value of the system property.
     */
    open fun getSystemProperty(name: String): String = SystemProperties.get(name)

    /** The display manager instance, or null if context is null. */
    val displayManager: DisplayManager? by lazy {
        context?.getSystemService(DisplayManager::class.java)
    }

    /** The window manager instance, or null if it cannot be retrieved. */
    val windowManager: IWindowManager? by lazy { WindowManagerGlobal.getWindowManagerService() }

    private fun wrapDmDisplay(display: Display, isEnabled: DisplayIsEnabled): DisplayDevice =
        DisplayDevice(display.displayId, display.name, display.mode,
                display.getSupportedModes().asList(), isEnabled)

    private fun isDisplayAllowed(display: Display): Boolean =
        display.type == Display.TYPE_EXTERNAL || display.type == Display.TYPE_OVERLAY
                || isVirtualDisplayAllowed(display);

    private fun isVirtualDisplayAllowed(display: Display): Boolean {
        val sysProp = getSystemProperty(VIRTUAL_DISPLAY_PACKAGE_NAME_SYSTEM_PROPERTY)
        return !sysProp.isEmpty() && display.type == Display.TYPE_VIRTUAL
                && sysProp == display.ownerPackageName
    }

    /**
     * @return all displays including disabled.
     */
    open fun getConnectedDisplays(): List<DisplayDevice> {
        val dm = displayManager ?: return emptyList()

        val enabledIds = dm.getDisplays().map { it.getDisplayId() }.toSet()

        return dm.getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED)
            .filter { isDisplayAllowed(it) }
            .map {
                val isEnabled = if (enabledIds.contains(it.displayId))
                    DisplayIsEnabled.YES
                else
                    DisplayIsEnabled.NO
                wrapDmDisplay(it, isEnabled)
            }
            .toList()
    }

    /**
     * @param displayId which must be returned
     * @return display object for the displayId, or null if display is not a connected display,
     *         the ID was not found, or the ID was invalid
     */
    open fun getDisplay(displayId: Int): DisplayDevice? {
        if (displayId == INVALID_DISPLAY) {
            return null
        }
        val display = displayManager?.getDisplay(displayId) ?: return null
        return if (isDisplayAllowed(display)) {
            wrapDmDisplay(display, DisplayIsEnabled.UNKNOWN)
        } else {
            null
        }
    }

    /**
     * Register display listener.
     */
    open fun registerDisplayListener(listener: DisplayManager.DisplayListener) {
        displayManager?.registerDisplayListener(listener, handler, EVENT_TYPE_DISPLAY_ADDED or
                EVENT_TYPE_DISPLAY_CHANGED or EVENT_TYPE_DISPLAY_REMOVED,
                PRIVATE_EVENT_TYPE_DISPLAY_CONNECTION_CHANGED)
    }

    /**
     * Unregister display listener.
     */
    open fun unregisterDisplayListener(listener: DisplayManager.DisplayListener) {
        displayManager?.unregisterDisplayListener(listener)
    }

    /**
     * Enable connected display.
     */
    open fun enableConnectedDisplay(displayId: Int): Boolean {
        val dm = displayManager ?: return false
        dm.enableConnectedDisplay(displayId)
        return true
    }

    /**
     * Disable connected display.
     */
    open fun disableConnectedDisplay(displayId: Int): Boolean {
        val dm = displayManager ?: return false
        dm.disableConnectedDisplay(displayId)
        return true
    }

    /**
     * Get display rotation
     * @param displayId display identifier
     * @return rotation
     */
    open fun getDisplayUserRotation(displayId: Int): Int {
        val wm = windowManager ?: return 0
        try {
            return wm.getDisplayUserRotation(displayId)
        } catch (e: RemoteException) {
            Log.e(TAG, "Error getting user rotation of display $displayId", e)
            return 0
        }
    }

    /**
     * Freeze rotation of the display in the specified rotation.
     * @param displayId display identifier
     * @param rotation [0, 1, 2, 3]
     * @return true if successful
     */
    open fun freezeDisplayRotation(displayId: Int, rotation: Int): Boolean {
        val wm = windowManager ?: return false
        try {
            wm.freezeDisplayRotation(displayId, rotation, "ExternalDisplayPreferenceFragment")
            return true
        } catch (e: RemoteException) {
            Log.e(TAG, "Error while freezing user rotation of display $displayId", e)
            return false
        }
    }

    /**
     * Enforce display mode on the given display.
     */
    open fun setUserPreferredDisplayMode(displayId: Int, mode: Display.Mode) {
        DisplayManagerGlobal.getInstance().setUserPreferredDisplayMode(displayId, mode)
    }

    /**
     * @return true if the display mode limit flag enabled.
     */
    open fun isModeLimitForExternalDisplayEnabled(): Boolean = enableModeLimitForExternalDisplay()

    open var displayTopology : DisplayTopology?
        get() = displayManager?.displayTopology
        set(value) { displayManager?.let { it.displayTopology = value } }

    open val wallpaper: Bitmap?
        get() = WallpaperManager.getInstance(context).bitmap

    /**
     * This density is the density of the current display (showing the Settings app UI). It is
     * necessary to use this density here because the topology pane coordinates are in physical
     * pixels, and the display bounds and accessibility constraints are in density-independent
     * pixels.
     */
    open val densityDpi: Int by lazy {
        val c = context
        val info = DisplayInfo()
        if (c != null && c.display.getDisplayInfo(info)) {
            info.logicalDensityDpi
        } else {
            DisplayMetrics.DENSITY_DEFAULT
        }
    }

    open fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
        val executor = context?.mainExecutor
        if (executor != null) displayManager?.registerTopologyListener(executor, listener)
    }

    open fun unregisterTopologyListener(listener: Consumer<DisplayTopology>) {
        displayManager?.unregisterTopologyListener(listener)
    }

    private companion object {
        private const val TAG = "ConnectedDisplayInjector"
    }
}
+56 −29
Original line number Diff line number Diff line
@@ -24,23 +24,25 @@ import android.graphics.Color
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.LayerDrawable
import android.widget.Button
import android.view.View
import android.widget.FrameLayout

import androidx.annotation.VisibleForTesting

/** Represents a draggable block in the topology pane. */
class DisplayBlock(context : Context) : Button(context) {
    @VisibleForTesting var mSelectedImage: Drawable = ColorDrawable(Color.BLACK)
    @VisibleForTesting var mUnselectedImage: Drawable = ColorDrawable(Color.BLACK)
class DisplayBlock(context : Context) : FrameLayout(context) {
    @VisibleForTesting
    val mHighlightPx = context.resources.getDimensionPixelSize(
            R.dimen.display_block_highlight_width)

    private val mSelectedBg = context.getDrawable(
            R.drawable.display_block_selection_marker_background)!!
    private val mUnselectedBg = context.getDrawable(
            R.drawable.display_block_unselected_background)!!
    private val mInsetPx = context.resources.getDimensionPixelSize(R.dimen.display_block_padding)
    val mWallpaperView = View(context)
    private val mBackgroundView = View(context).apply {
        background = context.getDrawable(R.drawable.display_block_background)
    }
    @VisibleForTesting
    val mSelectionMarkerView = View(context).apply {
        background = context.getDrawable(R.drawable.display_block_selection_marker_background)
    }

    init {
        isScrollContainer = false
@@ -49,27 +51,33 @@ class DisplayBlock(context : Context) : Button(context) {

        // Prevents shadow from appearing around edge of button.
        stateListAnimator = null
    }

    /** Sets position of the block given unpadded coordinates. */
    fun place(topLeft: PointF) {
        x = topLeft.x
        y = topLeft.y
        addView(mWallpaperView)
        addView(mBackgroundView)
        addView(mSelectionMarkerView)
    }

    fun setWallpaper(wallpaper: Bitmap?) {
        val wallpaperDrawable = BitmapDrawable(context.resources, wallpaper ?: return)

        fun framedBy(bg: Drawable): Drawable =
            LayerDrawable(arrayOf(wallpaperDrawable, bg)).apply {
                setLayerInsetRelative(0, mInsetPx, mInsetPx, mInsetPx, mInsetPx)
        mWallpaperView.background = BitmapDrawable(context.resources, wallpaper ?: return)
    }
        mSelectedImage = framedBy(mSelectedBg)
        mUnselectedImage = framedBy(mUnselectedBg)

    /**
     * The coordinates of the upper-left corner of the block in pane coordinates, not including the
     * highlight border.
     */
    var positionInPane: PointF
        get() = PointF(x + mHighlightPx, y + mHighlightPx)
        set(value: PointF) {
            x = value.x - mHighlightPx
            y = value.y - mHighlightPx
        }

    fun setHighlighted(value: Boolean) {
        background = if (value) mSelectedImage else mUnselectedImage
        mSelectionMarkerView.visibility = if (value) View.VISIBLE else View.INVISIBLE

        // The highlighted block must be draw last so that its highlight shows over the borders of
        // other displays.
        z = if (value) 2f else 1f
    }

    /** Sets position and size of the block given unpadded bounds. */
@@ -77,9 +85,28 @@ class DisplayBlock(context : Context) : Button(context) {
        val topLeft = scale.displayToPaneCoor(bounds.left, bounds.top)
        val bottomRight = scale.displayToPaneCoor(bounds.right, bounds.bottom)
        val layout = layoutParams
        layout.width = (bottomRight.x - topLeft.x).toInt()
        layout.height = (bottomRight.y - topLeft.y).toInt()
        val newWidth = (bottomRight.x - topLeft.x).toInt()
        val newHeight = (bottomRight.y - topLeft.y).toInt()
        layout.width = newWidth + 2*mHighlightPx
        layout.height = newHeight + 2*mHighlightPx
        layoutParams = layout
        place(topLeft)
        positionInPane = topLeft

        // The highlight is the outermost border. The highlight is shown outside of the parent
        // FrameLayout so that it consumes the padding between the blocks.
        mWallpaperView.layoutParams.let {
            it.width = newWidth
            it.height = newHeight
            if (it is MarginLayoutParams) {
                it.leftMargin = mHighlightPx
                it.topMargin = mHighlightPx
                it.bottomMargin = mHighlightPx
                it.topMargin = mHighlightPx
            }
            mWallpaperView.layoutParams = it
        }

        // The other two child views are MATCH_PARENT by default so will resize to fill up the
        // FrameLayout.
    }
}
+11 −54
Original line number Diff line number Diff line
@@ -24,10 +24,8 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF
import android.graphics.RectF
import android.hardware.display.DisplayManager
import android.hardware.display.DisplayTopology
import android.util.DisplayMetrics
import android.view.DisplayInfo
import android.view.MotionEvent
import android.view.ViewTreeObserver
import android.widget.FrameLayout
@@ -45,14 +43,13 @@ import kotlin.math.abs
 * DisplayTopologyPreference allows the user to change the display topology
 * when there is one or more extended display attached.
 */
class DisplayTopologyPreference(context : Context)
        : Preference(context), ViewTreeObserver.OnGlobalLayoutListener, GroupSectionDividerMixin {
class DisplayTopologyPreference(val injector: ConnectedDisplayInjector)
        : Preference(injector.context!!), ViewTreeObserver.OnGlobalLayoutListener,
          GroupSectionDividerMixin {
    @VisibleForTesting lateinit var mPaneContent : FrameLayout
    @VisibleForTesting lateinit var mPaneHolder : FrameLayout
    @VisibleForTesting lateinit var mTopologyHint : TextView

    @VisibleForTesting var injector : Injector

    /**
     * How many physical pixels to move in pane coordinates (Pythagorean distance) before a drag is
     * considered non-trivial and intentional.
@@ -84,8 +81,6 @@ class DisplayTopologyPreference(context : Context)
        isPersistent = false

        isCopyingEnabled = false

        injector = Injector(context)
    }

    override fun onBindViewHolder(holder: PreferenceViewHolder) {
@@ -125,44 +120,6 @@ class DisplayTopologyPreference(context : Context)
        }
    }

    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 var displayTopology : DisplayTopology?
            get() = displayManager.displayTopology
            set(value) { displayManager.displayTopology = value }

        open val wallpaper: Bitmap?
            get() = WallpaperManager.getInstance(context).bitmap

        /**
         * This density is the density of the current display (showing the topology pane). It is
         * necessary to use this density here because the topology pane coordinates are in physical
         * pixels, and the display coordinates are in density-independent pixels.
         */
        open val densityDpi: Int by lazy {
            val info = DisplayInfo()
            if (context.display.getDisplayInfo(info)) {
                info.logicalDensityDpi
            } else {
                DisplayMetrics.DENSITY_DEFAULT
            }
        }

        open fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
            displayManager.registerTopologyListener(context.mainExecutor, listener)
        }

        open fun unregisterTopologyListener(listener: Consumer<DisplayTopology>) {
            displayManager.unregisterTopologyListener(listener)
        }
    }

    /**
     * Holds information about the current system topology.
     * @param positions list of displays comprised of the display ID and position
@@ -311,9 +268,10 @@ class DisplayTopologyPreference(context : Context)
        // 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.
        val initialTopLeft = block.positionInPane
        mDrag = BlockDrag(
                stationaryDisps.toList(), block, displayId, displayPos.width(), displayPos.height(),
                initialBlockX = block.x, initialBlockY = block.y,
                initialBlockX = initialTopLeft.x, initialBlockY = initialTopLeft.y,
                initialTouchX = ev.rawX, initialTouchY = ev.rawY,
                startTimeMs = ev.eventTime,
        )
@@ -335,7 +293,7 @@ class DisplayTopologyPreference(context : Context)
                dispDragCoor.x + drag.displayWidth, dispDragCoor.y + drag.displayHeight)
        val snapRect = clampPosition(drag.stationaryDisps.map { it.second }, dispDragRect)

        drag.display.place(topology.scaling.displayToPaneCoor(snapRect.left, snapRect.top))
        drag.display.positionInPane = topology.scaling.displayToPaneCoor(snapRect.left, snapRect.top)

        return true
    }
@@ -346,18 +304,17 @@ class DisplayTopologyPreference(context : Context)
        mPaneContent.requestDisallowInterceptTouchEvent(false)
        drag.display.setHighlighted(false)

        val dropTopLeft = drag.display.positionInPane
        val netPxDragged = Math.hypot(
                (drag.initialBlockX - drag.display.x).toDouble(),
                (drag.initialBlockY - drag.display.y).toDouble())
                (drag.initialBlockX - dropTopLeft.x).toDouble(),
                (drag.initialBlockY - dropTopLeft.y).toDouble())
        val timeDownMs = ev.eventTime - drag.startTimeMs
        if (netPxDragged < accidentalDragDistancePx && timeDownMs < accidentalDragTimeLimitMs) {
            drag.display.x = drag.initialBlockX
            drag.display.y = drag.initialBlockY
            drag.display.positionInPane = PointF(drag.initialBlockX, drag.initialBlockY)
            return true
        }

        val newCoor = topology.scaling.paneToDisplayCoor(
                drag.display.x, drag.display.y)
        val newCoor = topology.scaling.paneToDisplayCoor(dropTopLeft.x, dropTopLeft.y)
        val newTopology = topology.topology.copy()
        val newPositions = drag.stationaryDisps.map { (id, pos) -> id to PointF(pos.left, pos.top) }
                .plus(drag.displayId to newCoor)
Loading