diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java index 0e42e6d6a83d6..7324b82351f63 100644 --- a/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java +++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java @@ -17,6 +17,7 @@ package com.android.server.accessibility; import android.content.Context; +import android.os.Handler; import android.os.PowerManager; import android.util.Pools.SimplePool; import android.util.Slog; @@ -30,6 +31,8 @@ import android.view.MotionEvent; import android.view.WindowManagerPolicy; import android.view.accessibility.AccessibilityEvent; +import com.android.server.LocalServices; + /** * This class is an input filter for implementing accessibility features such * as display magnification and explore by touch. @@ -425,7 +428,8 @@ class AccessibilityInputFilter extends InputFilter implements EventStreamTransfo } if ((mEnabledFeatures & FLAG_FEATURE_FILTER_KEY_EVENTS) != 0) { - mKeyboardInterceptor = new KeyboardInterceptor(mAms); + mKeyboardInterceptor = new KeyboardInterceptor(mAms, + LocalServices.getService(WindowManagerPolicy.class)); addFirstEventHandler(mKeyboardInterceptor); } } diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java index a58ba09e39374..a59844d2462ba 100644 --- a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java +++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java @@ -102,6 +102,7 @@ import android.view.accessibility.IAccessibilityManagerClient; import com.android.internal.R; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.os.SomeArgs; import com.android.internal.util.DumpUtils; @@ -903,7 +904,8 @@ public class AccessibilityManagerService extends IAccessibilityManager.Stub { } } - boolean notifyKeyEvent(KeyEvent event, int policyFlags) { + @VisibleForTesting + public boolean notifyKeyEvent(KeyEvent event, int policyFlags) { synchronized (mLock) { List boundServices = getCurrentUserStateLocked().mBoundServices; if (boundServices.isEmpty()) { diff --git a/services/accessibility/java/com/android/server/accessibility/KeyboardInterceptor.java b/services/accessibility/java/com/android/server/accessibility/KeyboardInterceptor.java index bbb25af5da437..f00a9540ef7d9 100644 --- a/services/accessibility/java/com/android/server/accessibility/KeyboardInterceptor.java +++ b/services/accessibility/java/com/android/server/accessibility/KeyboardInterceptor.java @@ -16,19 +16,52 @@ package com.android.server.accessibility; +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.util.Pools; +import android.util.Slog; import android.view.KeyEvent; import android.view.MotionEvent; +import android.view.WindowManagerPolicy; import android.view.accessibility.AccessibilityEvent; /** * Intercepts key events and forwards them to accessibility manager service. */ -public class KeyboardInterceptor implements EventStreamTransformation { - private EventStreamTransformation mNext; - private AccessibilityManagerService mAms; +public class KeyboardInterceptor implements EventStreamTransformation, Handler.Callback { + private static final int MESSAGE_PROCESS_QUEUED_EVENTS = 1; + private static final String LOG_TAG = "KeyboardInterceptor"; - public KeyboardInterceptor(AccessibilityManagerService service) { + private final AccessibilityManagerService mAms; + private final WindowManagerPolicy mPolicy; + private final Handler mHandler; + + private EventStreamTransformation mNext; + private KeyEventHolder mEventQueueStart; + private KeyEventHolder mEventQueueEnd; + + /** + * @param service The service to notify of key events + * @param policy The policy to check for keys that may affect a11y + */ + public KeyboardInterceptor(AccessibilityManagerService service, WindowManagerPolicy policy) { mAms = service; + mPolicy = policy; + mHandler = new Handler(this); + } + + /** + * @param service The service to notify of key events + * @param policy The policy to check for keys that may affect a11y + * @param handler The handler to use. Only used for testing. + */ + public KeyboardInterceptor(AccessibilityManagerService service, WindowManagerPolicy policy, + Handler handler) { + // Can't combine the constructors without making at least mHandler non-final. + mAms = service; + mPolicy = policy; + mHandler = handler; } @Override @@ -40,6 +73,19 @@ public class KeyboardInterceptor implements EventStreamTransformation { @Override public void onKeyEvent(KeyEvent event, int policyFlags) { + /* + * Certain keys have system-level behavior that affects accessibility services. + * Let that behavior settle before handling the keys + */ + long eventDelay = getEventDelay(event, policyFlags); + if (eventDelay < 0) { + return; + } + if ((eventDelay > 0) || (mEventQueueStart != null)) { + addEventToQueue(event, policyFlags, eventDelay); + return; + } + mAms.notifyKeyEvent(event, policyFlags); } @@ -65,4 +111,104 @@ public class KeyboardInterceptor implements EventStreamTransformation { @Override public void onDestroy() { } + + @Override + public boolean handleMessage(Message msg) { + if (msg.what != MESSAGE_PROCESS_QUEUED_EVENTS) { + Slog.e(LOG_TAG, "Unexpected message type"); + return false; + } + processQueuedEvents(); + if (mEventQueueStart != null) { + scheduleProcessQueuedEvents(); + } + return true; + } + + private void addEventToQueue(KeyEvent event, int policyFlags, long delay) { + long dispatchTime = SystemClock.uptimeMillis() + delay; + if (mEventQueueStart == null) { + mEventQueueEnd = mEventQueueStart = + KeyEventHolder.obtain(event, policyFlags, dispatchTime); + scheduleProcessQueuedEvents(); + return; + } + final KeyEventHolder holder = KeyEventHolder.obtain(event, policyFlags, dispatchTime); + holder.next = mEventQueueStart; + mEventQueueStart.previous = holder; + mEventQueueStart = holder; + } + + private void scheduleProcessQueuedEvents() { + if (!mHandler.sendEmptyMessageAtTime( + MESSAGE_PROCESS_QUEUED_EVENTS, mEventQueueEnd.dispatchTime)) { + Slog.e(LOG_TAG, "Failed to schedule key event"); + }; + } + + private void processQueuedEvents() { + final long currentTime = SystemClock.uptimeMillis(); + while ((mEventQueueEnd != null) && (mEventQueueEnd.dispatchTime <= currentTime)) { + final long eventDelay = getEventDelay(mEventQueueEnd.event, mEventQueueEnd.policyFlags); + if (eventDelay > 0) { + // Reschedule the event + mEventQueueEnd.dispatchTime = currentTime + eventDelay; + return; + } + // We'll either send or drop the event + if (eventDelay == 0) { + mAms.notifyKeyEvent(mEventQueueEnd.event, mEventQueueEnd.policyFlags); + } + final KeyEventHolder eventToBeRecycled = mEventQueueEnd; + mEventQueueEnd = mEventQueueEnd.previous; + if (mEventQueueEnd != null) { + mEventQueueEnd.next = null; + } + eventToBeRecycled.recycle(); + if (mEventQueueEnd == null) { + mEventQueueStart = null; + } + } + } + + private long getEventDelay(KeyEvent event, int policyFlags) { + int keyCode = event.getKeyCode(); + if ((keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) || (keyCode == KeyEvent.KEYCODE_VOLUME_UP)) { + return mPolicy.interceptKeyBeforeDispatching(null, event, policyFlags); + } + return 0; + } + + private static class KeyEventHolder { + private static final int MAX_POOL_SIZE = 32; + private static final Pools.SimplePool sPool = + new Pools.SimplePool<>(MAX_POOL_SIZE); + + public int policyFlags; + public long dispatchTime; + public KeyEvent event; + public KeyEventHolder next; + public KeyEventHolder previous; + + public static KeyEventHolder obtain(KeyEvent event, int policyFlags, long dispatchTime) { + KeyEventHolder holder = sPool.acquire(); + if (holder == null) { + holder = new KeyEventHolder(); + } + holder.event = KeyEvent.obtain(event); + holder.policyFlags = policyFlags; + holder.dispatchTime = dispatchTime; + return holder; + } + + public void recycle() { + event.recycle(); + event = null; + policyFlags = 0; + dispatchTime = 0; + next = null; + previous = null; + sPool.release(this); + } + } } diff --git a/services/tests/servicestests/src/com/android/server/accessibility/KeyboardInterceptorTest.java b/services/tests/servicestests/src/com/android/server/accessibility/KeyboardInterceptorTest.java new file mode 100644 index 0000000000000..5db397f85f1fa --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/KeyboardInterceptorTest.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2017 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; + +import static junit.framework.Assert.assertTrue; + +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyObject; +import static org.mockito.Matchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import android.os.Looper; +import android.support.test.runner.AndroidJUnit4; +import android.view.KeyEvent; +import android.view.WindowManagerPolicy; +import android.view.WindowManagerPolicy.WindowState; + +/** + * Tests for KeyboardInterceptor + */ +@RunWith(AndroidJUnit4.class) +public class KeyboardInterceptorTest { + private KeyboardInterceptor mInterceptor; + private MessageCapturingHandler mHandler = new MessageCapturingHandler( + msg -> mInterceptor.handleMessage(msg)); + @Mock AccessibilityManagerService mMockAms; + @Mock WindowManagerPolicy mMockPolicy; + + @BeforeClass + public static void oneTimeInitialization() { + if (Looper.myLooper() == null) { + Looper.prepare(); + } + } + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mInterceptor = new KeyboardInterceptor(mMockAms, mMockPolicy, mHandler); + } + + @Test + public void whenNonspecialKeyArrives_withNothingInQueue_eventGoesToAms() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_0); + mInterceptor.onKeyEvent(event, 0); + verify(mMockAms).notifyKeyEvent(argThat(matchesKeyEvent(event)), eq(0)); + } + + @Test + public void whenVolumeKeyArrives_andPolicySaysUseIt_eventGoesToAms() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_DOWN); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(0L); + mInterceptor.onKeyEvent(event, 0); + verify(mMockAms).notifyKeyEvent(argThat(matchesKeyEvent(event)), eq(0)); + } + + @Test + public void whenVolumeKeyArrives_andPolicySaysDropIt_eventDropped() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(-1L); + mInterceptor.onKeyEvent(event, 0); + verify(mMockAms, times(0)).notifyKeyEvent(anyObject(), anyInt()); + assertFalse(mHandler.hasMessages()); + } + + @Test + public void whenVolumeKeyArrives_andPolicySaysDelayThenUse_eventQueuedThenSentToAms() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(150L); + mInterceptor.onKeyEvent(event, 0); + + assertTrue(mHandler.hasMessages()); + verify(mMockAms, times(0)).notifyKeyEvent(anyObject(), anyInt()); + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(0L); + mHandler.sendAllMessages(); + + verify(mMockAms).notifyKeyEvent(argThat(matchesKeyEvent(event)), eq(0)); + } + + @Test + public void whenVolumeKeyArrives_andPolicySaysDelayThenDrop_eventQueuedThenDropped() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_DOWN); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(150L); + mInterceptor.onKeyEvent(event, 0); + + assertTrue(mHandler.hasMessages()); + verify(mMockAms, times(0)).notifyKeyEvent(anyObject(), anyInt()); + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(event)), eq(0))).thenReturn(-1L); + mHandler.sendAllMessages(); + + verify(mMockAms, times(0)).notifyKeyEvent(anyObject(), anyInt()); + } + + @Test + public void whenSomeEventsGetDelayed_allEventsStillInOrder() { + KeyEvent[] events = {new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_0), + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP), + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A), + new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_VOLUME_UP), + new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_0)}; + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[1])), eq(0))).thenReturn(150L); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[3])), eq(0))).thenReturn(75L); + + for (KeyEvent event : events) { + mInterceptor.onKeyEvent(event, 0); + } + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[1])), eq(0))).thenReturn(0L); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[3])), eq(0))).thenReturn(0L); + + mHandler.sendAllMessages(); + + InOrder inOrder = Mockito.inOrder(mMockAms); + for (KeyEvent event : events) { + inOrder.verify(mMockAms).notifyKeyEvent(argThat(matchesKeyEvent(event)), eq(0)); + } + } + + @Test + public void whenSomeEventsGetDropped_otherEventsStillInOrder() { + KeyEvent[] events = {new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_0), + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP), + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A), + new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_VOLUME_UP), + new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_0)}; + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[1])), eq(0))).thenReturn(150L); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[3])), eq(0))).thenReturn(75L); + + for (KeyEvent event : events) { + mInterceptor.onKeyEvent(event, 0); + } + + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[1])), eq(0))).thenReturn(-1L); + when(mMockPolicy.interceptKeyBeforeDispatching((WindowState) argThat(nullValue()), + argThat(matchesKeyEvent(events[3])), eq(0))).thenReturn(-1L); + + mHandler.sendAllMessages(); + + InOrder inOrder = Mockito.inOrder(mMockAms); + for (KeyEvent event : events) { + if ((event == events[1]) || (event == events[3])) continue; + inOrder.verify(mMockAms).notifyKeyEvent(argThat(matchesKeyEvent(event)), eq(0)); + } + } + + private static KeyEventMatcher matchesKeyEvent(KeyEvent event) { + return new KeyEventMatcher(event); + } + + private static class KeyEventMatcher extends TypeSafeMatcher { + private KeyEvent mEventToMatch; + + public KeyEventMatcher(KeyEvent eventToMatch) { + mEventToMatch = eventToMatch; + } + + @Override + protected boolean matchesSafely(KeyEvent item) { + return (mEventToMatch.getKeyCode() == item.getKeyCode()) + && (mEventToMatch.getAction() == item.getAction()); + } + + @Override + public void describeTo(Description description) { + description.appendText("Matches key event"); + } + } +} \ No newline at end of file