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