diff options
author | 2025-02-18 18:15:12 +0900 | |
---|---|---|
committer | 2025-03-06 16:42:14 -0800 | |
commit | 046f8df98d2d5a3bed05b3e2341bffc75f5fd1c9 (patch) | |
tree | 2dca0cb9866e1cdce7c70747e1ff66c4329c2891 | |
parent | eae8ce83aaa370a2efa08ddc338a607ecfbb8c4f (diff) |
Add end-to-end test for full screen magnification continuous mouse following
This adds an integration test for full screen magnification's mouse
following feature of continuous mode.
The test injects virtual mouse input event and verifies observed
magnification spec from accessibility service.
Bug: 361817142
Test: FullScreenMagnificationMouseFollowingTest
Flag: com.android.server.accessibility.enable_magnification_follows_mouse_with_pointer_motion_filter
Change-Id: I8c03c3012abd2aa39fc85c9992ce5c1ceb12fb73
4 files changed, 418 insertions, 0 deletions
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp index d702cae248a9..7a376320d9a9 100644 --- a/services/tests/servicestests/Android.bp +++ b/services/tests/servicestests/Android.bp @@ -62,6 +62,7 @@ android_test { "androidx.test.runner", "androidx.test.rules", "androidx.test.ext.junit", + "CtsAccessibilityCommon", "cts-wm-util", "platform-compat-test-rules", "platform-parametric-runner-lib", diff --git a/services/tests/servicestests/AndroidManifest.xml b/services/tests/servicestests/AndroidManifest.xml index 4531b3948495..ef478e8ff1e1 100644 --- a/services/tests/servicestests/AndroidManifest.xml +++ b/services/tests/servicestests/AndroidManifest.xml @@ -129,6 +129,21 @@ <application android:testOnly="true" android:debuggable="true"> <uses-library android:name="android.test.runner"/> + <service + android:name="com.android.server.accessibility.integration.FullScreenMagnificationMouseFollowingTest$TestMagnificationAccessibilityService" + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" + android:exported="true"> + <intent-filter> + <action android:name="android.accessibilityservice.AccessibilityService" /> + <category android:name="android.accessibilityservice.category.FEEDBACK_GENERIC" /> + </intent-filter> + + <meta-data + android:name="android.accessibilityservice" + android:resource="@xml/test_magnification_a11y_service" /> + </service> + <activity android:name="com.android.server.accessibility.integration.FullScreenMagnificationMouseFollowingTest$TestActivity" /> + <service android:name="com.android.server.accounts.TestAccountType1AuthenticatorService" android:exported="false"> <intent-filter> diff --git a/services/tests/servicestests/res/xml/test_magnification_a11y_service.xml b/services/tests/servicestests/res/xml/test_magnification_a11y_service.xml new file mode 100644 index 000000000000..d28cdca6a26a --- /dev/null +++ b/services/tests/servicestests/res/xml/test_magnification_a11y_service.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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. +--> +<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" + android:accessibilityEventTypes="typeAllMask" + android:accessibilityFeedbackType="feedbackGeneric" + android:canRetrieveWindowContent="true" + android:canRequestTouchExplorationMode="true" + android:canRequestEnhancedWebAccessibility="true" + android:canRequestFilterKeyEvents="true" + android:canControlMagnification="true" + android:canPerformGestures="true"/>
\ No newline at end of file diff --git a/services/tests/servicestests/src/com/android/server/accessibility/integration/FullScreenMagnificationMouseFollowingTest.kt b/services/tests/servicestests/src/com/android/server/accessibility/integration/FullScreenMagnificationMouseFollowingTest.kt new file mode 100644 index 000000000000..679bba4017fb --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/integration/FullScreenMagnificationMouseFollowingTest.kt @@ -0,0 +1,378 @@ +/* + * 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.server.accessibility.integration + +import android.Manifest +import android.accessibility.cts.common.AccessibilityDumpOnFailureRule +import android.accessibility.cts.common.InstrumentedAccessibilityService +import android.accessibility.cts.common.InstrumentedAccessibilityServiceTestRule +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.MagnificationConfig +import android.app.Activity +import android.app.Instrumentation +import android.app.UiAutomation +import android.companion.virtual.VirtualDeviceManager +import android.graphics.PointF +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.hardware.input.InputManager +import android.hardware.input.VirtualMouse +import android.hardware.input.VirtualMouseConfig +import android.hardware.input.VirtualMouseRelativeEvent +import android.os.Handler +import android.os.Looper +import android.os.OutcomeReceiver +import android.platform.test.annotations.RequiresFlagsEnabled +import android.testing.PollingCheck +import android.view.Display +import android.view.InputDevice +import android.view.MotionEvent +import android.virtualdevice.cts.common.VirtualDeviceRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.server.accessibility.Flags +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +// Convenient extension functions for float. +private const val EPS = 0.00001f +private fun Float.nearEq(other: Float) = abs(this - other) < EPS +private fun PointF.nearEq(other: PointF) = this.x.nearEq(other.x) && this.y.nearEq(other.y) + +/** End-to-end tests for full screen magnification following mouse cursor. */ +@RunWith(AndroidJUnit4::class) +@RequiresFlagsEnabled(Flags.FLAG_ENABLE_MAGNIFICATION_FOLLOWS_MOUSE_WITH_POINTER_MOTION_FILTER) +class FullScreenMagnificationMouseFollowingTest { + + private lateinit var instrumentation: Instrumentation + private lateinit var uiAutomation: UiAutomation + + private val magnificationAccessibilityServiceRule = + InstrumentedAccessibilityServiceTestRule<TestMagnificationAccessibilityService>( + TestMagnificationAccessibilityService::class.java, false + ) + private lateinit var service: TestMagnificationAccessibilityService + + // virtualDeviceRule tears down `virtualDevice` and `virtualDisplay`. + // Note that CheckFlagsRule is a part of VirtualDeviceRule. See its javadoc. + val virtualDeviceRule: VirtualDeviceRule = + VirtualDeviceRule.withAdditionalPermissions(Manifest.permission.MANAGE_ACTIVITY_TASKS) + private lateinit var virtualDevice: VirtualDeviceManager.VirtualDevice + private lateinit var virtualDisplay: VirtualDisplay + + // Once created, it's our responsibility to close the mouse. + private lateinit var virtualMouse: VirtualMouse + + @get:Rule + val ruleChain: RuleChain = + RuleChain.outerRule(virtualDeviceRule) + .around(magnificationAccessibilityServiceRule) + .around(AccessibilityDumpOnFailureRule()) + + @Before + fun setUp() { + instrumentation = InstrumentationRegistry.getInstrumentation() + uiAutomation = + instrumentation.getUiAutomation(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) + uiAutomation.serviceInfo = + uiAutomation.serviceInfo!!.apply { + flags = flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + + prepareVirtualDevices() + + launchTestActivityFullscreen(virtualDisplay.display.displayId) + + service = magnificationAccessibilityServiceRule.enableService() + service.observingDisplayId = virtualDisplay.display.displayId + } + + @After + fun cleanUp() { + if (this::virtualMouse.isInitialized) { + virtualMouse.close() + } + } + + // Note on continuous movement: + // Assume that the entire display is magnified, and the zoom level is z. + // In continuous movement, mouse speed relative to the unscaled physical display is the same as + // unmagnified speed. While, when a cursor moves from the left edge to the right edge of the + // screen, the magnification center moves from the left bound to the right bound, which is + // (display width) * (z - 1) / z. + // + // Similarly, when the mouse cursor moves by d in unscaled, display coordinates, + // the magnification center moves by d * (z - 1) / z. + + @Test + fun testContinuous_toBottomRight() { + ensureMouseAtCenter() + + val controller = service.getMagnificationController(virtualDisplay.display.displayId) + + scaleTo(controller, 2f) + assertMagnification(controller, scale = 2f, CENTER_X, CENTER_Y) + + // Move cursor by (10, 15) + // This will move magnification center by (5, 7.5) + sendMouseMove(10f, 15f) + assertCursorLocation(CENTER_X + 10, CENTER_Y + 15) + assertMagnification(controller, scale = 2f, CENTER_X + 5, CENTER_Y + 7.5f) + + // Move cursor to the rest of the way to the edge. + sendMouseMove(DISPLAY_WIDTH - 10, DISPLAY_HEIGHT - 15) + assertCursorLocation(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1) + assertMagnification(controller, scale = 2f, DISPLAY_WIDTH * 3 / 4, DISPLAY_HEIGHT * 3 / 4) + + // Move cursor further won't move the magnification. + sendMouseMove(100f, 100f) + assertCursorLocation(DISPLAY_WIDTH - 1, DISPLAY_HEIGHT - 1) + } + + @Test + fun testContinuous_toTopLeft() { + ensureMouseAtCenter() + + val controller = service.getMagnificationController(virtualDisplay.display.displayId) + + scaleTo(controller, 3f) + assertMagnification(controller, scale = 3f, CENTER_X, CENTER_Y) + + // Move cursor by (-30, -15) + // This will move magnification center by (-20, -10) + sendMouseMove(-30f, -15f) + assertCursorLocation(CENTER_X - 30, CENTER_Y - 15) + assertMagnification(controller, scale = 3f, CENTER_X - 20, CENTER_Y - 10) + + // Move cursor to the rest of the way to the edge. + sendMouseMove(-CENTER_X + 30, -CENTER_Y + 15) + assertCursorLocation(0f, 0f) + assertMagnification(controller, scale = 3f, DISPLAY_WIDTH / 6, DISPLAY_HEIGHT / 6) + + // Move cursor further won't move the magnification. + sendMouseMove(-100f, -100f) + assertCursorLocation(0f, 0f) + assertMagnification(controller, scale = 3f, DISPLAY_WIDTH / 6, DISPLAY_HEIGHT / 6) + } + + private fun ensureMouseAtCenter() { + val displayCenter = PointF(320f, 240f) + val cursorLocation = virtualMouse.cursorPosition + if (!cursorLocation.nearEq(displayCenter)) { + sendMouseMove(displayCenter.x - cursorLocation.x, displayCenter.y - cursorLocation.y) + assertCursorLocation(320f, 240f) + } + } + + private fun sendMouseMove(dx: Float, dy: Float) { + virtualMouse.sendRelativeEvent( + VirtualMouseRelativeEvent.Builder().setRelativeX(dx).setRelativeY(dy).build() + ) + } + + /** + * Asserts that the cursor location is at the specified coordinates. The coordinates + * are in the non-scaled, display coordinates. + */ + private fun assertCursorLocation(x: Float, y: Float) { + PollingCheck.check("Wait for the cursor at ($x, $y)", CURSOR_TIMEOUT.inWholeMilliseconds) { + service.lastObservedCursorLocation?.let { it.x.nearEq(x) && it.y.nearEq(y) } ?: false + } + } + + private fun scaleTo(controller: AccessibilityService.MagnificationController, scale: Float) { + val config = + MagnificationConfig.Builder() + .setActivated(true) + .setMode(MagnificationConfig.MAGNIFICATION_MODE_FULLSCREEN) + .setScale(scale) + .build() + val setResult = BooleanArray(1) + service.runOnServiceSync { setResult[0] = controller.setMagnificationConfig(config, false) } + assertThat(setResult[0]).isTrue() + } + + private fun assertMagnification( + controller: AccessibilityService.MagnificationController, + scale: Float = Float.NaN, centerX: Float = Float.NaN, centerY: Float = Float.NaN + ) { + PollingCheck.check( + "Wait for the magnification to scale=$scale, centerX=$centerX, centerY=$centerY", + MAGNIFICATION_TIMEOUT.inWholeMilliseconds + ) check@{ + val actual = controller.getMagnificationConfig() ?: return@check false + actual.isActivated && + (actual.mode == MagnificationConfig.MAGNIFICATION_MODE_FULLSCREEN) && + (scale.isNaN() || scale.nearEq(actual.scale)) && + (centerX.isNaN() || centerX.nearEq(actual.centerX)) && + (centerY.isNaN() || centerY.nearEq(actual.centerY)) + } + } + + /** + * Sets up a virtual display and a virtual mouse for the test. The virtual mouse is associated + * with the virtual display. + */ + private fun prepareVirtualDevices() { + val deviceLatch = CountDownLatch(1) + val im = instrumentation.context.getSystemService(InputManager::class.java) + val inputDeviceListener = + object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) { + onInputDeviceChanged(deviceId) + } + + override fun onInputDeviceRemoved(deviceId: Int) {} + + override fun onInputDeviceChanged(deviceId: Int) { + val device = im.getInputDevice(deviceId) ?: return + if (device.vendorId == VIRTUAL_MOUSE_VENDOR_ID && + device.productId == VIRTUAL_MOUSE_PRODUCT_ID + ) { + deviceLatch.countDown() + } + } + } + im.registerInputDeviceListener(inputDeviceListener, Handler(Looper.getMainLooper())) + + virtualDevice = virtualDeviceRule.createManagedVirtualDevice() + virtualDisplay = + virtualDeviceRule.createManagedVirtualDisplay( + virtualDevice, + VirtualDeviceRule + .createDefaultVirtualDisplayConfigBuilder( + DISPLAY_WIDTH.toInt(), + DISPLAY_HEIGHT.toInt() + ) + .setFlags( + DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC + or DisplayManager.VIRTUAL_DISPLAY_FLAG_TRUSTED + or DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY + ) + )!! + virtualMouse = + virtualDevice.createVirtualMouse( + VirtualMouseConfig.Builder() + .setVendorId(VIRTUAL_MOUSE_VENDOR_ID) + .setProductId(VIRTUAL_MOUSE_PRODUCT_ID) + .setAssociatedDisplayId(virtualDisplay.display.displayId) + .setInputDeviceName("VirtualMouse") + .build() + ) + + deviceLatch.await(UI_IDLE_GLOBAL_TIMEOUT.inWholeSeconds, TimeUnit.SECONDS) + im.unregisterInputDeviceListener(inputDeviceListener) + } + + /** + * Launches a test (empty) activity and makes it fullscreen on the specified display. This + * ensures that system bars are hidden and the full screen magnification enlarges the entire + * display. + */ + private fun launchTestActivityFullscreen(displayId: Int) { + val future = CompletableFuture<Void?>() + val fullscreenCallback = + object : OutcomeReceiver<Void, Throwable> { + override fun onResult(result: Void?) { + future.complete(null) + } + + override fun onError(error: Throwable) { + future.completeExceptionally(error) + } + } + + val activity = + virtualDeviceRule.startActivityOnDisplaySync<TestActivity>( + displayId, + TestActivity::class.java + ) + instrumentation.runOnMainSync { + activity.requestFullscreenMode( + Activity.FULLSCREEN_MODE_REQUEST_ENTER, + fullscreenCallback + ) + } + future.get(UI_IDLE_GLOBAL_TIMEOUT.inWholeSeconds, TimeUnit.SECONDS) + + uiAutomation.waitForIdle( + UI_IDLE_TIMEOUT.inWholeMilliseconds, UI_IDLE_GLOBAL_TIMEOUT.inWholeMilliseconds + ) + } + + class TestMagnificationAccessibilityService : InstrumentedAccessibilityService() { + private val lock = Any() + + var observingDisplayId = Display.INVALID_DISPLAY + set(v) { + synchronized(lock) { field = v } + } + + var lastObservedCursorLocation: PointF? = null + private set + get() { + synchronized(lock) { + return field + } + } + + override fun onServiceConnected() { + serviceInfo = + getServiceInfo()!!.apply { setMotionEventSources(InputDevice.SOURCE_MOUSE) } + + super.onServiceConnected() + } + + override fun onMotionEvent(event: MotionEvent) { + super.onMotionEvent(event) + + synchronized(lock) { + if (event.displayId == observingDisplayId) { + lastObservedCursorLocation = PointF(event.x, event.y) + } + } + } + } + + class TestActivity : Activity() + + companion object { + private const val VIRTUAL_MOUSE_VENDOR_ID = 123 + private const val VIRTUAL_MOUSE_PRODUCT_ID = 456 + + private val CURSOR_TIMEOUT = 1.seconds + private val MAGNIFICATION_TIMEOUT = 3.seconds + private val UI_IDLE_TIMEOUT = 500.milliseconds + private val UI_IDLE_GLOBAL_TIMEOUT = 5.seconds + + private const val DISPLAY_WIDTH = 640.0f + private const val DISPLAY_HEIGHT = 480.0f + private const val CENTER_X = DISPLAY_WIDTH / 2f + private const val CENTER_Y = DISPLAY_HEIGHT / 2f + } +} |