• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.connecteddevice.display
18 
19 import com.android.settings.R
20 import com.android.settingslib.widget.GroupSectionDividerMixin
21 
22 import android.app.WallpaperManager
23 import android.content.Context
24 import android.graphics.Bitmap
25 import android.graphics.PointF
26 import android.graphics.RectF
27 import android.hardware.display.DisplayManager
28 import android.hardware.display.DisplayTopology
29 import android.util.DisplayMetrics
30 import android.view.DisplayInfo
31 import android.view.MotionEvent
32 import android.view.ViewTreeObserver
33 import android.widget.FrameLayout
34 import android.widget.TextView
35 
36 import androidx.annotation.VisibleForTesting
37 import androidx.preference.Preference
38 import androidx.preference.PreferenceViewHolder
39 
40 import java.util.function.Consumer
41 
42 import kotlin.math.abs
43 
44 /**
45  * DisplayTopologyPreference allows the user to change the display topology
46  * when there is one or more extended display attached.
47  */
48 class DisplayTopologyPreference(context : Context)
49         : Preference(context), ViewTreeObserver.OnGlobalLayoutListener, GroupSectionDividerMixin {
50     @VisibleForTesting lateinit var mPaneContent : FrameLayout
51     @VisibleForTesting lateinit var mPaneHolder : FrameLayout
52     @VisibleForTesting lateinit var mTopologyHint : TextView
53 
54     @VisibleForTesting var injector : Injector
55 
56     /**
57      * How many physical pixels to move in pane coordinates (Pythagorean distance) before a drag is
58      * considered non-trivial and intentional.
59      *
60      * This value is computed on-demand so that the injector can be changed at any time.
61      */
62     @VisibleForTesting val accidentalDragDistancePx
63         get() = DisplayTopology.dpToPx(4f, injector.densityDpi)
64 
65     /**
66      * How long before until a tap is considered a drag regardless of distance moved.
67      */
68     @VisibleForTesting val accidentalDragTimeLimitMs = 800L
69 
70     /**
71      * This is needed to prevent a repopulation of the pane causing another
72      * relayout and vice-versa ad infinitum.
73      */
74     private var mPaneNeedsRefresh = false
75 
76     private val mTopologyListener = Consumer<DisplayTopology> { applyTopology(it) }
77 
78     init {
79         layoutResource = R.layout.display_topology_preference
80 
81         // Prevent highlight when hovering with mouse.
82         isSelectable = false
83 
84         isPersistent = false
85 
86         isCopyingEnabled = false
87 
88         injector = Injector(context)
89     }
90 
91     override fun onBindViewHolder(holder: PreferenceViewHolder) {
92         super.onBindViewHolder(holder)
93 
94         val newPane = holder.findViewById(R.id.display_topology_pane_content) as FrameLayout
95         if (this::mPaneContent.isInitialized) {
96             if (newPane == mPaneContent) {
97                 return
98             }
99             mPaneContent.viewTreeObserver.removeOnGlobalLayoutListener(this)
100         }
101         mPaneContent = newPane
102         mPaneHolder = holder.itemView as FrameLayout
103         mTopologyHint = holder.findViewById(R.id.topology_hint) as TextView
104         mPaneContent.viewTreeObserver.addOnGlobalLayoutListener(this)
105     }
106 
107     override fun onAttached() {
108         super.onAttached()
109         // We don't know if topology changes happened when we were detached, as it is impossible to
110         // listen at that time (we must remove listeners when detaching). Setting this flag makes
111         // the following onGlobalLayout call refresh the pane.
112         mPaneNeedsRefresh = true
113         injector.registerTopologyListener(mTopologyListener)
114     }
115 
116     override fun onDetached() {
117         super.onDetached()
118         injector.unregisterTopologyListener(mTopologyListener)
119     }
120 
121     override fun onGlobalLayout() {
122         if (mPaneNeedsRefresh) {
123             mPaneNeedsRefresh = false
124             refreshPane()
125         }
126     }
127 
128     open class Injector(val context : Context) {
129         /**
130          * Lazy property for Display Manager, to prevent eagerly getting the service in unit tests.
131          */
132         private val displayManager : DisplayManager by lazy {
133             context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
134         }
135 
136         open var displayTopology : DisplayTopology?
137             get() = displayManager.displayTopology
138             set(value) { displayManager.displayTopology = value }
139 
140         open val wallpaper: Bitmap?
141             get() = WallpaperManager.getInstance(context).bitmap
142 
143         /**
144          * This density is the density of the current display (showing the topology pane). It is
145          * necessary to use this density here because the topology pane coordinates are in physical
146          * pixels, and the display coordinates are in density-independent pixels.
147          */
148         open val densityDpi: Int by lazy {
149             val info = DisplayInfo()
150             if (context.display.getDisplayInfo(info)) {
151                 info.logicalDensityDpi
152             } else {
153                 DisplayMetrics.DENSITY_DEFAULT
154             }
155         }
156 
157         open fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
158             displayManager.registerTopologyListener(context.mainExecutor, listener)
159         }
160 
161         open fun unregisterTopologyListener(listener: Consumer<DisplayTopology>) {
162             displayManager.unregisterTopologyListener(listener)
163         }
164     }
165 
166     /**
167      * Holds information about the current system topology.
168      * @param positions list of displays comprised of the display ID and position
169      */
170     private data class TopologyInfo(
171             val topology: DisplayTopology, val scaling: TopologyScale,
172             val positions: List<Pair<Int, RectF>>)
173 
174     /**
175      * Holds information about the current drag operation. The initial rawX, rawY values of the
176      * cursor are recorded in order to detect whether the drag was a substantial drag or likely
177      * accidental.
178      *
179      * @param stationaryDisps ID and position of displays that are not moving
180      * @param display View that is currently being dragged
181      * @param displayId ID of display being dragged
182      * @param displayWidth width of display being dragged in actual (not View) coordinates
183      * @param displayHeight height of display being dragged in actual (not View) coordinates
184      * @param initialBlockX block's X coordinate upon touch down event
185      * @param initialBlockY block's Y coordinate upon touch down event
186      * @param initialTouchX rawX value of the touch down event
187      * @param initialTouchY rawY value of the touch down event
188      * @param startTimeMs time when tap down occurred, needed to detect the user intentionally
189      *                    wanted to drag rather than just click
190      */
191     private data class BlockDrag(
192             val stationaryDisps : List<Pair<Int, RectF>>,
193             val display: DisplayBlock, val displayId: Int,
194             val displayWidth: Float, val displayHeight: Float,
195             val initialBlockX: Float, val initialBlockY: Float,
196             val initialTouchX: Float, val initialTouchY: Float,
197             val startTimeMs: Long)
198 
199     private var mTopologyInfo : TopologyInfo? = null
200     private var mDrag : BlockDrag? = null
201 
202     private fun sameDisplayPosition(a: RectF, b: RectF): Boolean {
203         // Comparing in display coordinates, so a 1 pixel difference will be less than one dp in
204         // pane coordinates. Canceling the drag and refreshing the pane will not change the apparent
205         // position of displays in the pane.
206         val EPSILON = 1f
207         return EPSILON > abs(a.left - b.left) &&
208                 EPSILON > abs(a.right - b.right) &&
209                 EPSILON > abs(a.top - b.top) &&
210                 EPSILON > abs(a.bottom - b.bottom)
211     }
212 
213     @VisibleForTesting fun refreshPane() {
214         val topology = injector.displayTopology
215         if (topology == null) {
216             // This occurs when no topology is active.
217             // TODO(b/352648432): show main display or mirrored displays rather than an empty pane.
218             mTopologyHint.text = ""
219             mPaneContent.removeAllViews()
220             mTopologyInfo = null
221             return
222         }
223 
224         applyTopology(topology)
225     }
226 
227     @VisibleForTesting var mTimesRefreshedBlocks = 0
228 
229     private fun applyTopology(topology: DisplayTopology) {
230         mTopologyHint.text = context.getString(R.string.external_display_topology_hint)
231 
232         val oldBounds = mTopologyInfo?.positions
233         val newBounds = buildList {
234             val bounds = topology.absoluteBounds
235             (0..bounds.size()-1).forEach {
236                 add(Pair(bounds.keyAt(it), bounds.valueAt(it)))
237             }
238         }
239 
240         if (oldBounds != null && oldBounds.size == newBounds.size &&
241                 oldBounds.zip(newBounds).all { (old, new) ->
242                     old.first == new.first && sameDisplayPosition(old.second, new.second)
243                 }) {
244             return
245         }
246 
247         val recycleableBlocks = ArrayDeque<DisplayBlock>()
248         for (i in 0..mPaneContent.childCount-1) {
249             recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock)
250         }
251 
252         val scaling = TopologyScale(
253                 mPaneContent.width,
254                 minEdgeLength = DisplayTopology.dpToPx(60f, injector.densityDpi),
255                 maxEdgeLength = DisplayTopology.dpToPx(256f, injector.densityDpi),
256                 newBounds.map { it.second }.toList())
257         mPaneHolder.layoutParams.let {
258             val newHeight = scaling.paneHeight.toInt()
259             if (it.height != newHeight) {
260                 it.height = newHeight
261                 mPaneHolder.layoutParams = it
262             }
263         }
264 
265         var wallpaperBitmap : Bitmap? = null
266 
267         newBounds.forEach { (id, pos) ->
268             val block = recycleableBlocks.removeFirstOrNull() ?: DisplayBlock(context).apply {
269                 if (wallpaperBitmap == null) {
270                     wallpaperBitmap = injector.wallpaper
271                 }
272                 // We need a separate wallpaper Drawable for each display block, since each needs to
273                 // be drawn at a separate size.
274                 setWallpaper(wallpaperBitmap)
275 
276                 mPaneContent.addView(this)
277             }
278             block.setHighlighted(false)
279 
280             block.placeAndSize(pos, scaling)
281             block.setOnTouchListener { view, ev ->
282                 when (ev.actionMasked) {
283                     MotionEvent.ACTION_DOWN -> onBlockTouchDown(id, pos, block, ev)
284                     MotionEvent.ACTION_MOVE -> onBlockTouchMove(ev)
285                     MotionEvent.ACTION_UP -> onBlockTouchUp(ev)
286                     else -> false
287                 }
288             }
289         }
290         mPaneContent.removeViews(newBounds.size, recycleableBlocks.size)
291         mTimesRefreshedBlocks++
292 
293         mTopologyInfo = TopologyInfo(topology, scaling, newBounds)
294 
295         // Cancel the drag if one is in progress.
296         mDrag = null
297     }
298 
299     private fun onBlockTouchDown(
300             displayId: Int, displayPos: RectF, block: DisplayBlock, ev: MotionEvent): Boolean {
301         val positions = (mTopologyInfo ?: return false).positions
302 
303         // Do not allow dragging for single-display topology, since there is nothing to clamp it to.
304         if (positions.size <= 1) { return false }
305 
306         val stationaryDisps = positions.filter { it.first != displayId }
307 
308         mDrag?.display?.setHighlighted(false)
309         block.setHighlighted(true)
310 
311         // We have to use rawX and rawY for the coordinates since the view receiving the event is
312         // also the view that is moving. We need coordinates relative to something that isn't
313         // moving, and the raw coordinates are relative to the screen.
314         mDrag = BlockDrag(
315                 stationaryDisps.toList(), block, displayId, displayPos.width(), displayPos.height(),
316                 initialBlockX = block.x, initialBlockY = block.y,
317                 initialTouchX = ev.rawX, initialTouchY = ev.rawY,
318                 startTimeMs = ev.eventTime,
319         )
320 
321         // Prevents a container of this view from intercepting the touch events in the case the
322         // pointer moves outside of the display block or the pane.
323         mPaneContent.requestDisallowInterceptTouchEvent(true)
324         return true
325     }
326 
327     private fun onBlockTouchMove(ev: MotionEvent): Boolean {
328         val drag = mDrag ?: return false
329         val topology = mTopologyInfo ?: return false
330         val dispDragCoor = topology.scaling.paneToDisplayCoor(
331                 ev.rawX - drag.initialTouchX + drag.initialBlockX,
332                 ev.rawY - drag.initialTouchY + drag.initialBlockY)
333         val dispDragRect = RectF(
334                 dispDragCoor.x, dispDragCoor.y,
335                 dispDragCoor.x + drag.displayWidth, dispDragCoor.y + drag.displayHeight)
336         val snapRect = clampPosition(drag.stationaryDisps.map { it.second }, dispDragRect)
337 
338         drag.display.place(topology.scaling.displayToPaneCoor(snapRect.left, snapRect.top))
339 
340         return true
341     }
342 
343     private fun onBlockTouchUp(ev: MotionEvent): Boolean {
344         val drag = mDrag ?: return false
345         val topology = mTopologyInfo ?: return false
346         mPaneContent.requestDisallowInterceptTouchEvent(false)
347         drag.display.setHighlighted(false)
348 
349         val netPxDragged = Math.hypot(
350                 (drag.initialBlockX - drag.display.x).toDouble(),
351                 (drag.initialBlockY - drag.display.y).toDouble())
352         val timeDownMs = ev.eventTime - drag.startTimeMs
353         if (netPxDragged < accidentalDragDistancePx && timeDownMs < accidentalDragTimeLimitMs) {
354             drag.display.x = drag.initialBlockX
355             drag.display.y = drag.initialBlockY
356             return true
357         }
358 
359         val newCoor = topology.scaling.paneToDisplayCoor(
360                 drag.display.x, drag.display.y)
361         val newTopology = topology.topology.copy()
362         val newPositions = drag.stationaryDisps.map { (id, pos) -> id to PointF(pos.left, pos.top) }
363                 .plus(drag.displayId to newCoor)
364 
365         val arr = hashMapOf(*newPositions.toTypedArray())
366         newTopology.rearrange(arr)
367 
368         // Setting mTopologyInfo to null forces applyTopology to skip the no-op drag check. This is
369         // necessary because we don't know if newTopology.rearrange has mutated the topology away
370         // from what the user has dragged into position.
371         mTopologyInfo = null
372         applyTopology(newTopology)
373 
374         injector.displayTopology = newTopology
375 
376         return true
377     }
378 }
379