summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Treehugger Robot <android-test-infra-autosubmit@system.gserviceaccount.com> 2025-03-23 17:29:29 -0700
committer Android (Google) Code Review <android-gerrit@google.com> 2025-03-23 17:29:29 -0700
commitf25afeda601b29fd3aa5830c65dab6a2fba55f82 (patch)
tree73b5c0fe3f19767ed43a4c9a909689255b986e40
parent3fb807abdff3ecc41e63d6e1967b0f908f75f7ce (diff)
parent046f8df98d2d5a3bed05b3e2341bffc75f5fd1c9 (diff)
Merge "Add end-to-end test for full screen magnification continuous mouse following" into main
-rw-r--r--services/tests/servicestests/Android.bp1
-rw-r--r--services/tests/servicestests/AndroidManifest.xml15
-rw-r--r--services/tests/servicestests/res/xml/test_magnification_a11y_service.xml24
-rw-r--r--services/tests/servicestests/src/com/android/server/accessibility/integration/FullScreenMagnificationMouseFollowingTest.kt378
4 files changed, 418 insertions, 0 deletions
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index d3c3178f3513..a99e1b1f28e1 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -63,6 +63,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
+ }
+}