1 /*
2 * Copyright (C) 2025 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 package com.android.server.accessibility.integration
17
18 import android.Manifest
19 import android.accessibility.cts.common.AccessibilityDumpOnFailureRule
20 import android.accessibility.cts.common.InstrumentedAccessibilityService
21 import android.accessibility.cts.common.InstrumentedAccessibilityServiceTestRule
22 import android.accessibilityservice.AccessibilityService
23 import android.accessibilityservice.AccessibilityServiceInfo
24 import android.accessibilityservice.MagnificationConfig
25 import android.app.Activity
26 import android.app.Instrumentation
27 import android.app.UiAutomation
28 import android.companion.virtual.VirtualDeviceManager
29 import android.graphics.PointF
30 import android.hardware.display.DisplayManager
31 import android.hardware.display.VirtualDisplay
32 import android.hardware.input.InputManager
33 import android.hardware.input.VirtualMouse
34 import android.hardware.input.VirtualMouseConfig
35 import android.hardware.input.VirtualMouseRelativeEvent
36 import android.os.Handler
37 import android.os.Looper
38 import android.os.OutcomeReceiver
39 import android.platform.test.annotations.RequiresFlagsEnabled
40 import android.testing.PollingCheck
41 import android.view.Display
42 import android.view.InputDevice
43 import android.view.MotionEvent
44 import android.virtualdevice.cts.common.VirtualDeviceRule
45 import androidx.test.ext.junit.runners.AndroidJUnit4
46 import androidx.test.platform.app.InstrumentationRegistry
47 import com.android.server.accessibility.Flags
48 import com.google.common.truth.Truth.assertThat
49 import java.util.concurrent.CompletableFuture
50 import java.util.concurrent.CountDownLatch
51 import java.util.concurrent.TimeUnit
52 import kotlin.math.abs
53 import kotlin.time.Duration.Companion.milliseconds
54 import kotlin.time.Duration.Companion.seconds
55 import org.junit.After
56 import org.junit.Before
57 import org.junit.Rule
58 import org.junit.Test
59 import org.junit.rules.RuleChain
60 import org.junit.runner.RunWith
61
62 // Convenient extension functions for float.
63 private const val EPS = 0.00001f
Floatnull64 private fun Float.nearEq(other: Float) = abs(this - other) < EPS
65 private fun PointF.nearEq(other: PointF) = this.x.nearEq(other.x) && this.y.nearEq(other.y)
66
67 /** End-to-end tests for full screen magnification following mouse cursor. */
68 @RunWith(AndroidJUnit4::class)
69 @RequiresFlagsEnabled(Flags.FLAG_ENABLE_MAGNIFICATION_FOLLOWS_MOUSE_WITH_POINTER_MOTION_FILTER)
70 class FullScreenMagnificationMouseFollowingTest {
71
72 private lateinit var instrumentation: Instrumentation
73 private lateinit var uiAutomation: UiAutomation
74
75 private val magnificationAccessibilityServiceRule =
76 InstrumentedAccessibilityServiceTestRule<TestMagnificationAccessibilityService>(
77 TestMagnificationAccessibilityService::class.java, false
78 )
79 private lateinit var service: TestMagnificationAccessibilityService
80
81 // virtualDeviceRule tears down `virtualDevice` and `virtualDisplay`.
82 // Note that CheckFlagsRule is a part of VirtualDeviceRule. See its javadoc.
83 val virtualDeviceRule: VirtualDeviceRule =
84 VirtualDeviceRule.withAdditionalPermissions(Manifest.permission.MANAGE_ACTIVITY_TASKS)
85 private lateinit var virtualDevice: VirtualDeviceManager.VirtualDevice
86 private lateinit var virtualDisplay: VirtualDisplay
87
88 // Once created, it's our responsibility to close the mouse.
89 private lateinit var virtualMouse: VirtualMouse
90
91 @get:Rule
92 val ruleChain: RuleChain =
93 RuleChain.outerRule(virtualDeviceRule)
94 .around(magnificationAccessibilityServiceRule)
95 .around(AccessibilityDumpOnFailureRule())
96
97 @Before
98 fun setUp() {
99 instrumentation = InstrumentationRegistry.getInstrumentation()
100 uiAutomation =
101 instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
102 uiAutomation.serviceInfo =
103 uiAutomation.serviceInfo!!.apply {
104 flags = flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
105 }
106
107 prepareVirtualDevices()
108
109 launchTestActivityFullscreen(virtualDisplay.display.displayId)
110
111 service = magnificationAccessibilityServiceRule.enableService()
112 service.observingDisplayId = virtualDisplay.display.displayId
113 }
114
115 @After
116 fun cleanUp() {
117 if (this::virtualMouse.isInitialized) {
118 virtualMouse.close()
119 }
120 }
121
122 // Note on continuous movement:
123 // Assume that the entire display is magnified, and the zoom level is z.
124 // In continuous movement, mouse speed relative to the unscaled physical display is the same as
125 // unmagnified speed. While, when a cursor moves from the left edge to the right edge of the
126 // screen, the magnification center moves from the left bound to the right bound, which is
127 // (display width) * (z - 1) / z.
128 //
129 // Similarly, when the mouse cursor moves by d in unscaled, display coordinates,
130 // the magnification center moves by d * (z - 1) / z.
131
132 @Test
133 fun testContinuous_toBottomRight() {
134 ensureMouseAtCenter()
135
136 val controller = service.getMagnificationController(virtualDisplay.display.displayId)
137
138 scaleTo(controller, 2f)
139 assertMagnification(controller, scale = 2f, CENTER_X, CENTER_Y)
140
141 // Move cursor by (10, 15)
142 // This will move magnification center by (5, 7.5)
143 sendMouseMove(10f, 15f)
144 assertCursorLocation(CENTER_X + 10, CENTER_Y + 15)
145 assertMagnification(controller, scale = 2f, CENTER_X + 5, CENTER_Y + 7.5f)
146
147 // Move cursor to the rest of the way to the edge.
148 sendMouseMove(DISPLAY_WIDTH - 10, DISPLAY_HEIGHT - 15)
149 assertCursorLocation(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1)
150 assertMagnification(controller, scale = 2f, DISPLAY_WIDTH * 3 / 4, DISPLAY_HEIGHT * 3 / 4)
151
152 // Move cursor further won't move the magnification.
153 sendMouseMove(100f, 100f)
154 assertCursorLocation(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1)
155 }
156
157 @Test
158 fun testContinuous_toTopLeft() {
159 ensureMouseAtCenter()
160
161 val controller = service.getMagnificationController(virtualDisplay.display.displayId)
162
163 scaleTo(controller, 3f)
164 assertMagnification(controller, scale = 3f, CENTER_X, CENTER_Y)
165
166 // Move cursor by (-30, -15)
167 // This will move magnification center by (-20, -10)
168 sendMouseMove(-30f, -15f)
169 assertCursorLocation(CENTER_X - 30, CENTER_Y - 15)
170 assertMagnification(controller, scale = 3f, CENTER_X - 20, CENTER_Y - 10)
171
172 // Move cursor to the rest of the way to the edge.
173 sendMouseMove(-CENTER_X + 30, -CENTER_Y + 15)
174 assertCursorLocation(0f, 0f)
175 assertMagnification(controller, scale = 3f, DISPLAY_WIDTH / 6, DISPLAY_HEIGHT / 6)
176
177 // Move cursor further won't move the magnification.
178 sendMouseMove(-100f, -100f)
179 assertCursorLocation(0f, 0f)
180 assertMagnification(controller, scale = 3f, DISPLAY_WIDTH / 6, DISPLAY_HEIGHT / 6)
181 }
182
183 private fun ensureMouseAtCenter() {
184 val displayCenter = PointF(320f, 240f)
185 val cursorLocation = virtualMouse.cursorPosition
186 if (!cursorLocation.nearEq(displayCenter)) {
187 sendMouseMove(displayCenter.x - cursorLocation.x, displayCenter.y - cursorLocation.y)
188 assertCursorLocation(320f, 240f)
189 }
190 }
191
192 private fun sendMouseMove(dx: Float, dy: Float) {
193 virtualMouse.sendRelativeEvent(
194 VirtualMouseRelativeEvent.Builder().setRelativeX(dx).setRelativeY(dy).build()
195 )
196 }
197
198 /**
199 * Asserts that the cursor location is at the specified coordinates. The coordinates
200 * are in the non-scaled, display coordinates.
201 */
202 private fun assertCursorLocation(x: Float, y: Float) {
203 PollingCheck.check("Wait for the cursor at ($x, $y)", CURSOR_TIMEOUT.inWholeMilliseconds) {
204 service.lastObservedCursorLocation?.let { it.x.nearEq(x) && it.y.nearEq(y) } ?: false
205 }
206 }
207
208 private fun scaleTo(controller: AccessibilityService.MagnificationController, scale: Float) {
209 val config =
210 MagnificationConfig.Builder()
211 .setActivated(true)
212 .setMode(MagnificationConfig.MAGNIFICATION_MODE_FULLSCREEN)
213 .setScale(scale)
214 .build()
215 val setResult = BooleanArray(1)
216 service.runOnServiceSync { setResult[0] = controller.setMagnificationConfig(config, false) }
217 assertThat(setResult[0]).isTrue()
218 }
219
220 private fun assertMagnification(
221 controller: AccessibilityService.MagnificationController,
222 scale: Float = Float.NaN, centerX: Float = Float.NaN, centerY: Float = Float.NaN
223 ) {
224 PollingCheck.check(
225 "Wait for the magnification to scale=$scale, centerX=$centerX, centerY=$centerY",
226 MAGNIFICATION_TIMEOUT.inWholeMilliseconds
227 ) check@{
228 val actual = controller.getMagnificationConfig() ?: return@check false
229 actual.isActivated &&
230 (actual.mode == MagnificationConfig.MAGNIFICATION_MODE_FULLSCREEN) &&
231 (scale.isNaN() || scale.nearEq(actual.scale)) &&
232 (centerX.isNaN() || centerX.nearEq(actual.centerX)) &&
233 (centerY.isNaN() || centerY.nearEq(actual.centerY))
234 }
235 }
236
237 /**
238 * Sets up a virtual display and a virtual mouse for the test. The virtual mouse is associated
239 * with the virtual display.
240 */
241 private fun prepareVirtualDevices() {
242 val deviceLatch = CountDownLatch(1)
243 val im = instrumentation.context.getSystemService(InputManager::class.java)
244 val inputDeviceListener =
245 object : InputManager.InputDeviceListener {
246 override fun onInputDeviceAdded(deviceId: Int) {
247 onInputDeviceChanged(deviceId)
248 }
249
250 override fun onInputDeviceRemoved(deviceId: Int) {}
251
252 override fun onInputDeviceChanged(deviceId: Int) {
253 val device = im.getInputDevice(deviceId) ?: return
254 if (device.vendorId == VIRTUAL_MOUSE_VENDOR_ID &&
255 device.productId == VIRTUAL_MOUSE_PRODUCT_ID
256 ) {
257 deviceLatch.countDown()
258 }
259 }
260 }
261 im.registerInputDeviceListener(inputDeviceListener, Handler(Looper.getMainLooper()))
262
263 virtualDevice = virtualDeviceRule.createManagedVirtualDevice()
264 virtualDisplay =
265 virtualDeviceRule.createManagedVirtualDisplay(
266 virtualDevice,
267 VirtualDeviceRule
268 .createDefaultVirtualDisplayConfigBuilder(
269 DISPLAY_WIDTH.toInt(),
270 DISPLAY_HEIGHT.toInt()
271 )
272 .setFlags(
273 DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC
274 or DisplayManager.VIRTUAL_DISPLAY_FLAG_TRUSTED
275 or DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
276 )
277 )!!
278 virtualMouse =
279 virtualDevice.createVirtualMouse(
280 VirtualMouseConfig.Builder()
281 .setVendorId(VIRTUAL_MOUSE_VENDOR_ID)
282 .setProductId(VIRTUAL_MOUSE_PRODUCT_ID)
283 .setAssociatedDisplayId(virtualDisplay.display.displayId)
284 .setInputDeviceName("VirtualMouse")
285 .build()
286 )
287
288 deviceLatch.await(UI_IDLE_GLOBAL_TIMEOUT.inWholeSeconds, TimeUnit.SECONDS)
289 im.unregisterInputDeviceListener(inputDeviceListener)
290 }
291
292 /**
293 * Launches a test (empty) activity and makes it fullscreen on the specified display. This
294 * ensures that system bars are hidden and the full screen magnification enlarges the entire
295 * display.
296 */
297 private fun launchTestActivityFullscreen(displayId: Int) {
298 val future = CompletableFuture<Void?>()
299 val fullscreenCallback =
300 object : OutcomeReceiver<Void, Throwable> {
301 override fun onResult(result: Void?) {
302 future.complete(null)
303 }
304
305 override fun onError(error: Throwable) {
306 future.completeExceptionally(error)
307 }
308 }
309
310 val activity =
311 virtualDeviceRule.startActivityOnDisplaySync<TestActivity>(
312 displayId,
313 TestActivity::class.java
314 )
315 instrumentation.runOnMainSync {
316 activity.requestFullscreenMode(
317 Activity.FULLSCREEN_MODE_REQUEST_ENTER,
318 fullscreenCallback
319 )
320 }
321 future.get(UI_IDLE_GLOBAL_TIMEOUT.inWholeSeconds, TimeUnit.SECONDS)
322
323 uiAutomation.waitForIdle(
324 UI_IDLE_TIMEOUT.inWholeMilliseconds, UI_IDLE_GLOBAL_TIMEOUT.inWholeMilliseconds
325 )
326 }
327
328 class TestMagnificationAccessibilityService : InstrumentedAccessibilityService() {
329 private val lock = Any()
330
331 var observingDisplayId = Display.INVALID_DISPLAY
332 set(v) {
333 synchronized(lock) { field = v }
334 }
335
336 var lastObservedCursorLocation: PointF? = null
337 private set
338 get() {
339 synchronized(lock) {
340 return field
341 }
342 }
343
344 override fun onServiceConnected() {
345 serviceInfo =
346 getServiceInfo()!!.apply { setMotionEventSources(InputDevice.SOURCE_MOUSE) }
347
348 super.onServiceConnected()
349 }
350
351 override fun onMotionEvent(event: MotionEvent) {
352 super.onMotionEvent(event)
353
354 synchronized(lock) {
355 if (event.displayId == observingDisplayId) {
356 lastObservedCursorLocation = PointF(event.x, event.y)
357 }
358 }
359 }
360 }
361
362 class TestActivity : Activity()
363
364 companion object {
365 private const val VIRTUAL_MOUSE_VENDOR_ID = 123
366 private const val VIRTUAL_MOUSE_PRODUCT_ID = 456
367
368 private val CURSOR_TIMEOUT = 1.seconds
369 private val MAGNIFICATION_TIMEOUT = 3.seconds
370 private val UI_IDLE_TIMEOUT = 500.milliseconds
371 private val UI_IDLE_GLOBAL_TIMEOUT = 5.seconds
372
373 private const val DISPLAY_WIDTH = 640.0f
374 private const val DISPLAY_HEIGHT = 480.0f
375 private const val CENTER_X = DISPLAY_WIDTH / 2f
376 private const val CENTER_Y = DISPLAY_HEIGHT / 2f
377 }
378 }
379