blob: f5b0eb1eb5b68faed357edef603ccaa0d3ccf7e5 [file] [log] [blame]
/*
* Copyright (C) 2015 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 android.annotation.NonNull;
import android.content.ContentResolver;
import android.content.Context;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import android.os.SystemClock;
import android.provider.Settings;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.MotionEvent.PointerCoords;
import android.view.MotionEvent.PointerProperties;
import android.view.accessibility.AccessibilityManager;
/**
* Implements "Automatically click on mouse stop" feature.
*
* If enabled, it will observe motion events from mouse source, and send click event sequence
* shortly after mouse stops moving. The click will only be performed if mouse movement had been
* actually detected.
*
* Movement detection has tolerance to jitter that may be caused by poor motor control to prevent:
* <ul>
* <li>Initiating unwanted clicks with no mouse movement.</li>
* <li>Autoclick never occurring after mouse arriving at target.</li>
* </ul>
*
* Non-mouse motion events, key events (excluding modifiers) and non-movement mouse events cancel
* the automatic click.
*
* It is expected that each instance will receive mouse events from a single mouse device. User of
* the class should handle cases where multiple mouse devices are present.
*
* Each instance is associated to a single user (and it does not handle user switch itself).
*/
public class AutoclickController extends BaseEventStreamTransformation {
private static final String LOG_TAG = AutoclickController.class.getSimpleName();
private final Context mContext;
private final int mUserId;
// Lazily created on the first mouse motion event.
private ClickScheduler mClickScheduler;
private ClickDelayObserver mClickDelayObserver;
public AutoclickController(Context context, int userId) {
mContext = context;
mUserId = userId;
}
@Override
public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
if (mClickScheduler == null) {
Handler handler = new Handler(mContext.getMainLooper());
mClickScheduler =
new ClickScheduler(handler, AccessibilityManager.AUTOCLICK_DELAY_DEFAULT);
mClickDelayObserver = new ClickDelayObserver(mUserId, handler);
mClickDelayObserver.start(mContext.getContentResolver(), mClickScheduler);
}
handleMouseMotion(event, policyFlags);
} else if (mClickScheduler != null) {
mClickScheduler.cancel();
}
super.onMotionEvent(event, rawEvent, policyFlags);
}
@Override
public void onKeyEvent(KeyEvent event, int policyFlags) {
if (mClickScheduler != null) {
if (KeyEvent.isModifierKey(event.getKeyCode())) {
mClickScheduler.updateMetaState(event.getMetaState());
} else {
mClickScheduler.cancel();
}
}
super.onKeyEvent(event, policyFlags);
}
@Override
public void clearEvents(int inputSource) {
if (inputSource == InputDevice.SOURCE_MOUSE && mClickScheduler != null) {
mClickScheduler.cancel();
}
super.clearEvents(inputSource);
}
@Override
public void onDestroy() {
if (mClickDelayObserver != null) {
mClickDelayObserver.stop();
mClickDelayObserver = null;
}
if (mClickScheduler != null) {
mClickScheduler.cancel();
mClickScheduler = null;
}
}
private void handleMouseMotion(MotionEvent event, int policyFlags) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_HOVER_MOVE: {
if (event.getPointerCount() == 1) {
mClickScheduler.update(event, policyFlags);
} else {
mClickScheduler.cancel();
}
} break;
// Ignore hover enter and exit.
case MotionEvent.ACTION_HOVER_ENTER:
case MotionEvent.ACTION_HOVER_EXIT:
break;
default:
mClickScheduler.cancel();
}
}
/**
* Observes setting value for autoclick delay, and updates ClickScheduler delay whenever the
* setting value changes.
*/
final private static class ClickDelayObserver extends ContentObserver {
/** URI used to identify the autoclick delay setting with content resolver. */
private final Uri mAutoclickDelaySettingUri = Settings.Secure.getUriFor(
Settings.Secure.ACCESSIBILITY_AUTOCLICK_DELAY);
private ContentResolver mContentResolver;
private ClickScheduler mClickScheduler;
private final int mUserId;
public ClickDelayObserver(int userId, Handler handler) {
super(handler);
mUserId = userId;
}
/**
* Starts the observer. And makes sure up-to-date autoclick delay is propagated to
* |clickScheduler|.
*
* @param contentResolver Content resolver that should be observed for setting's value
* changes.
* @param clickScheduler ClickScheduler that should be updated when click delay changes.
* @throws IllegalStateException If internal state is already setup when the method is
* called.
* @throws NullPointerException If any of the arguments is a null pointer.
*/
public void start(@NonNull ContentResolver contentResolver,
@NonNull ClickScheduler clickScheduler) {
if (mContentResolver != null || mClickScheduler != null) {
throw new IllegalStateException("Observer already started.");
}
if (contentResolver == null) {
throw new NullPointerException("contentResolver not set.");
}
if (clickScheduler == null) {
throw new NullPointerException("clickScheduler not set.");
}
mContentResolver = contentResolver;
mClickScheduler = clickScheduler;
mContentResolver.registerContentObserver(mAutoclickDelaySettingUri, false, this,
mUserId);
// Initialize mClickScheduler's initial delay value.
onChange(true, mAutoclickDelaySettingUri);
}
/**
* Stops the the observer. Should only be called if the observer has been started.
*
* @throws IllegalStateException If internal state hasn't yet been initialized by calling
* {@link #start}.
*/
public void stop() {
if (mContentResolver == null || mClickScheduler == null) {
throw new IllegalStateException("ClickDelayObserver not started.");
}
mContentResolver.unregisterContentObserver(this);
}
@Override
public void onChange(boolean selfChange, Uri uri) {
if (mAutoclickDelaySettingUri.equals(uri)) {
int delay = Settings.Secure.getIntForUser(
mContentResolver, Settings.Secure.ACCESSIBILITY_AUTOCLICK_DELAY,
AccessibilityManager.AUTOCLICK_DELAY_DEFAULT, mUserId);
mClickScheduler.updateDelay(delay);
}
}
}
/**
* Schedules and performs click event sequence that should be initiated when mouse pointer stops
* moving. The click is first scheduled when a mouse movement is detected, and then further
* delayed on every sufficient mouse movement.
*/
final private class ClickScheduler implements Runnable {
/**
* Minimal distance pointer has to move relative to anchor in order for movement not to be
* discarded as noise. Anchor is the position of the last MOVE event that was not considered
* noise.
*/
private static final double MOVEMENT_SLOPE = 20f;
/** Whether there is pending click. */
private boolean mActive;
/** If active, time at which pending click is scheduled. */
private long mScheduledClickTime;
/** Last observed motion event. null if no events have been observed yet. */
private MotionEvent mLastMotionEvent;
/** Last observed motion event's policy flags. */
private int mEventPolicyFlags;
/** Current meta state. This value will be used as meta state for click event sequence. */
private int mMetaState;
/**
* The current anchor's coordinates. Should be ignored if #mLastMotionEvent is null.
* Note that these are not necessary coords of #mLastMotionEvent (because last observed
* motion event may have been labeled as noise).
*/
private PointerCoords mAnchorCoords;
/** Delay that should be used to schedule click. */
private int mDelay;
/** Handler for scheduling delayed operations. */
private Handler mHandler;
private PointerProperties mTempPointerProperties[];
private PointerCoords mTempPointerCoords[];
public ClickScheduler(Handler handler, int delay) {
mHandler = handler;
mLastMotionEvent = null;
resetInternalState();
mDelay = delay;
mAnchorCoords = new PointerCoords();
}
@Override
public void run() {
long now = SystemClock.uptimeMillis();
// Click was rescheduled after task was posted. Post new run task at updated time.
if (now < mScheduledClickTime) {
mHandler.postDelayed(this, mScheduledClickTime - now);
return;
}
sendClick();
resetInternalState();
}
/**
* Updates properties that should be used for click event sequence initiated by this object,
* as well as the time at which click will be scheduled.
* Should be called whenever new motion event is observed.
*
* @param event Motion event whose properties should be used as a base for click event
* sequence.
* @param policyFlags Policy flags that should be send with click event sequence.
*/
public void update(MotionEvent event, int policyFlags) {
mMetaState = event.getMetaState();
boolean moved = detectMovement(event);
cacheLastEvent(event, policyFlags, mLastMotionEvent == null || moved /* useAsAnchor */);
if (moved) {
rescheduleClick(mDelay);
}
}
/** Cancels any pending clicks and resets the object state. */
public void cancel() {
if (!mActive) {
return;
}
resetInternalState();
mHandler.removeCallbacks(this);
}
/**
* Updates the meta state that should be used for click sequence.
*/
public void updateMetaState(int state) {
mMetaState = state;
}
/**
* Updates delay that should be used when scheduling clicks. The delay will be used only for
* clicks scheduled after this point (pending click tasks are not affected).
* @param delay New delay value.
*/
public void updateDelay(int delay) {
mDelay = delay;
}
/**
* Updates the time at which click sequence should occur.
*
* @param delay Delay (from now) after which click should occur.
*/
private void rescheduleClick(int delay) {
long clickTime = SystemClock.uptimeMillis() + delay;
// If there already is a scheduled click at time before the updated time, just update
// scheduled time. The click will actually be rescheduled when pending callback is
// run.
if (mActive && clickTime > mScheduledClickTime) {
mScheduledClickTime = clickTime;
return;
}
if (mActive) {
mHandler.removeCallbacks(this);
}
mActive = true;
mScheduledClickTime = clickTime;
mHandler.postDelayed(this, delay);
}
/**
* Updates last observed motion event.
*
* @param event The last observed event.
* @param policyFlags The policy flags used with the last observed event.
* @param useAsAnchor Whether the event coords should be used as a new anchor.
*/
private void cacheLastEvent(MotionEvent event, int policyFlags, boolean useAsAnchor) {
if (mLastMotionEvent != null) {
mLastMotionEvent.recycle();
}
mLastMotionEvent = MotionEvent.obtain(event);
mEventPolicyFlags = policyFlags;
if (useAsAnchor) {
final int pointerIndex = mLastMotionEvent.getActionIndex();
mLastMotionEvent.getPointerCoords(pointerIndex, mAnchorCoords);
}
}
private void resetInternalState() {
mActive = false;
if (mLastMotionEvent != null) {
mLastMotionEvent.recycle();
mLastMotionEvent = null;
}
mScheduledClickTime = -1;
}
/**
* @param event Observed motion event.
* @return Whether the event coords are far enough from the anchor for the event not to be
* considered noise.
*/
private boolean detectMovement(MotionEvent event) {
if (mLastMotionEvent == null) {
return false;
}
final int pointerIndex = event.getActionIndex();
float deltaX = mAnchorCoords.x - event.getX(pointerIndex);
float deltaY = mAnchorCoords.y - event.getY(pointerIndex);
double delta = Math.hypot(deltaX, deltaY);
return delta > MOVEMENT_SLOPE;
}
/**
* Creates and forwards click event sequence.
*/
private void sendClick() {
if (mLastMotionEvent == null || getNext() == null) {
return;
}
final int pointerIndex = mLastMotionEvent.getActionIndex();
if (mTempPointerProperties == null) {
mTempPointerProperties = new PointerProperties[1];
mTempPointerProperties[0] = new PointerProperties();
}
mLastMotionEvent.getPointerProperties(pointerIndex, mTempPointerProperties[0]);
if (mTempPointerCoords == null) {
mTempPointerCoords = new PointerCoords[1];
mTempPointerCoords[0] = new PointerCoords();
}
mLastMotionEvent.getPointerCoords(pointerIndex, mTempPointerCoords[0]);
final long now = SystemClock.uptimeMillis();
MotionEvent downEvent = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 1,
mTempPointerProperties, mTempPointerCoords, mMetaState,
MotionEvent.BUTTON_PRIMARY, 1.0f, 1.0f, mLastMotionEvent.getDeviceId(), 0,
mLastMotionEvent.getSource(), mLastMotionEvent.getFlags());
// The only real difference between these two events is the action flag.
MotionEvent upEvent = MotionEvent.obtain(downEvent);
upEvent.setAction(MotionEvent.ACTION_UP);
AutoclickController.super.onMotionEvent(downEvent, downEvent, mEventPolicyFlags);
downEvent.recycle();
AutoclickController.super.onMotionEvent(upEvent, upEvent, mEventPolicyFlags);
upEvent.recycle();
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("ClickScheduler: { active=").append(mActive);
builder.append(", delay=").append(mDelay);
builder.append(", scheduledClickTime=").append(mScheduledClickTime);
builder.append(", anchor={x:").append(mAnchorCoords.x);
builder.append(", y:").append(mAnchorCoords.y).append("}");
builder.append(", metastate=").append(mMetaState);
builder.append(", policyFlags=").append(mEventPolicyFlags);
builder.append(", lastMotionEvent=").append(mLastMotionEvent);
builder.append(" }");
return builder.toString();
}
}
}