1/*
2 * Copyright (C) 2015 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
17package android.accessibilityservice;
18
19import android.annotation.IntRange;
20import android.annotation.NonNull;
21import android.graphics.Path;
22import android.graphics.PathMeasure;
23import android.graphics.RectF;
24import android.view.InputDevice;
25import android.view.MotionEvent;
26import android.view.MotionEvent.PointerCoords;
27import android.view.MotionEvent.PointerProperties;
28
29import java.util.ArrayList;
30import java.util.List;
31
32/**
33 * Accessibility services with the
34 * {@link android.R.styleable#AccessibilityService_canPerformGestures} property can dispatch
35 * gestures. This class describes those gestures. Gestures are made up of one or more strokes.
36 * Gestures are immutable once built.
37 * <p>
38 * Spatial dimensions throughout are in screen pixels. Time is measured in milliseconds.
39 */
40public final class GestureDescription {
41    /** Gestures may contain no more than this many strokes */
42    private static final int MAX_STROKE_COUNT = 10;
43
44    /**
45     * Upper bound on total gesture duration. Nearly all gestures will be much shorter.
46     */
47    private static final long MAX_GESTURE_DURATION_MS = 60 * 1000;
48
49    private final List<StrokeDescription> mStrokes = new ArrayList<>();
50    private final float[] mTempPos = new float[2];
51
52    /**
53     * Get the upper limit for the number of strokes a gesture may contain.
54     *
55     * @return The maximum number of strokes.
56     */
57    public static int getMaxStrokeCount() {
58        return MAX_STROKE_COUNT;
59    }
60
61    /**
62     * Get the upper limit on a gesture's duration.
63     *
64     * @return The maximum duration in milliseconds.
65     */
66    public static long getMaxGestureDuration() {
67        return MAX_GESTURE_DURATION_MS;
68    }
69
70    private GestureDescription() {}
71
72    private GestureDescription(List<StrokeDescription> strokes) {
73        mStrokes.addAll(strokes);
74    }
75
76    /**
77     * Get the number of stroke in the gesture.
78     *
79     * @return the number of strokes in this gesture
80     */
81    public int getStrokeCount() {
82        return mStrokes.size();
83    }
84
85    /**
86     * Read a stroke from the gesture
87     *
88     * @param index the index of the stroke
89     *
90     * @return A description of the stroke.
91     */
92    public StrokeDescription getStroke(@IntRange(from = 0) int index) {
93        return mStrokes.get(index);
94    }
95
96    /**
97     * Return the smallest key point (where a path starts or ends) that is at least a specified
98     * offset
99     * @param offset the minimum start time
100     * @return The next key time that is at least the offset or -1 if one can't be found
101     */
102    private long getNextKeyPointAtLeast(long offset) {
103        long nextKeyPoint = Long.MAX_VALUE;
104        for (int i = 0; i < mStrokes.size(); i++) {
105            long thisStartTime = mStrokes.get(i).mStartTime;
106            if ((thisStartTime < nextKeyPoint) && (thisStartTime >= offset)) {
107                nextKeyPoint = thisStartTime;
108            }
109            long thisEndTime = mStrokes.get(i).mEndTime;
110            if ((thisEndTime < nextKeyPoint) && (thisEndTime >= offset)) {
111                nextKeyPoint = thisEndTime;
112            }
113        }
114        return (nextKeyPoint == Long.MAX_VALUE) ? -1L : nextKeyPoint;
115    }
116
117    /**
118     * Get the points that correspond to a particular moment in time.
119     * @param time The time of interest
120     * @param touchPoints An array to hold the current touch points. Must be preallocated to at
121     * least the number of paths in the gesture to prevent going out of bounds
122     * @return The number of points found, and thus the number of elements set in each array
123     */
124    private int getPointsForTime(long time, TouchPoint[] touchPoints) {
125        int numPointsFound = 0;
126        for (int i = 0; i < mStrokes.size(); i++) {
127            StrokeDescription strokeDescription = mStrokes.get(i);
128            if (strokeDescription.hasPointForTime(time)) {
129                touchPoints[numPointsFound].mPathIndex = i;
130                touchPoints[numPointsFound].mIsStartOfPath = (time == strokeDescription.mStartTime);
131                touchPoints[numPointsFound].mIsEndOfPath = (time == strokeDescription.mEndTime);
132                strokeDescription.getPosForTime(time, mTempPos);
133                touchPoints[numPointsFound].mX = Math.round(mTempPos[0]);
134                touchPoints[numPointsFound].mY = Math.round(mTempPos[1]);
135                numPointsFound++;
136            }
137        }
138        return numPointsFound;
139    }
140
141    // Total duration assumes that the gesture starts at 0; waiting around to start a gesture
142    // counts against total duration
143    private static long getTotalDuration(List<StrokeDescription> paths) {
144        long latestEnd = Long.MIN_VALUE;
145        for (int i = 0; i < paths.size(); i++) {
146            StrokeDescription path = paths.get(i);
147            latestEnd = Math.max(latestEnd, path.mEndTime);
148        }
149        return Math.max(latestEnd, 0);
150    }
151
152    /**
153     * Builder for a {@code GestureDescription}
154     */
155    public static class Builder {
156
157        private final List<StrokeDescription> mStrokes = new ArrayList<>();
158
159        /**
160         * Add a stroke to the gesture description. Up to
161         * {@link GestureDescription#getMaxStrokeCount()} paths may be
162         * added to a gesture, and the total gesture duration (earliest path start time to latest
163         * path end time) may not exceed {@link GestureDescription#getMaxGestureDuration()}.
164         *
165         * @param strokeDescription the stroke to add.
166         *
167         * @return this
168         */
169        public Builder addStroke(@NonNull StrokeDescription strokeDescription) {
170            if (mStrokes.size() >= MAX_STROKE_COUNT) {
171                throw new IllegalStateException(
172                        "Attempting to add too many strokes to a gesture");
173            }
174
175            mStrokes.add(strokeDescription);
176
177            if (getTotalDuration(mStrokes) > MAX_GESTURE_DURATION_MS) {
178                mStrokes.remove(strokeDescription);
179                throw new IllegalStateException(
180                        "Gesture would exceed maximum duration with new stroke");
181            }
182            return this;
183        }
184
185        public GestureDescription build() {
186            if (mStrokes.size() == 0) {
187                throw new IllegalStateException("Gestures must have at least one stroke");
188            }
189            return new GestureDescription(mStrokes);
190        }
191    }
192
193    /**
194     * Immutable description of stroke that can be part of a gesture.
195     */
196    public static class StrokeDescription {
197        Path mPath;
198        long mStartTime;
199        long mEndTime;
200        private float mTimeToLengthConversion;
201        private PathMeasure mPathMeasure;
202        // The tap location is only set for zero-length paths
203        float[] mTapLocation;
204
205        /**
206         * @param path The path to follow. Must have exactly one contour. The bounds of the path
207         * must not be negative. The path must not be empty. If the path has zero length
208         * (for example, a single {@code moveTo()}), the stroke is a touch that doesn't move.
209         * @param startTime The time, in milliseconds, from the time the gesture starts to the
210         * time the stroke should start. Must not be negative.
211         * @param duration The duration, in milliseconds, the stroke takes to traverse the path.
212         * Must not be negative.
213         */
214        public StrokeDescription(@NonNull Path path,
215                @IntRange(from = 0) long startTime,
216                @IntRange(from = 0) long duration) {
217            if (duration <= 0) {
218                throw new IllegalArgumentException("Duration must be positive");
219            }
220            if (startTime < 0) {
221                throw new IllegalArgumentException("Start time must not be negative");
222            }
223            RectF bounds = new RectF();
224            path.computeBounds(bounds, false /* unused */);
225            if ((bounds.bottom < 0) || (bounds.top < 0) || (bounds.right < 0)
226                    || (bounds.left < 0)) {
227                throw new IllegalArgumentException("Path bounds must not be negative");
228            }
229            if (path.isEmpty()) {
230                throw new IllegalArgumentException("Path is empty");
231            }
232            mPath = new Path(path);
233            mPathMeasure = new PathMeasure(path, false);
234            if (mPathMeasure.getLength() == 0) {
235                // Treat zero-length paths as taps
236                Path tempPath = new Path(path);
237                tempPath.lineTo(-1, -1);
238                mTapLocation = new float[2];
239                PathMeasure pathMeasure = new PathMeasure(tempPath, false);
240                pathMeasure.getPosTan(0, mTapLocation, null);
241            }
242            if (mPathMeasure.nextContour()) {
243                throw new IllegalArgumentException("Path has more than one contour");
244            }
245            /*
246             * Calling nextContour has moved mPathMeasure off the first contour, which is the only
247             * one we care about. Set the path again to go back to the first contour.
248             */
249            mPathMeasure.setPath(mPath, false);
250            mStartTime = startTime;
251            mEndTime = startTime + duration;
252            mTimeToLengthConversion = getLength() / duration;
253        }
254
255        /**
256         * Retrieve a copy of the path for this stroke
257         *
258         * @return A copy of the path
259         */
260        public Path getPath() {
261            return new Path(mPath);
262        }
263
264        /**
265         * Get the stroke's start time
266         *
267         * @return the start time for this stroke.
268         */
269        public long getStartTime() {
270            return mStartTime;
271        }
272
273        /**
274         * Get the stroke's duration
275         *
276         * @return the duration for this stroke
277         */
278        public long getDuration() {
279            return mEndTime - mStartTime;
280        }
281
282        float getLength() {
283            return mPathMeasure.getLength();
284        }
285
286        /* Assumes hasPointForTime returns true */
287        boolean getPosForTime(long time, float[] pos) {
288            if (mTapLocation != null) {
289                pos[0] = mTapLocation[0];
290                pos[1] = mTapLocation[1];
291                return true;
292            }
293            if (time == mEndTime) {
294                // Close to the end time, roundoff can be a problem
295                return mPathMeasure.getPosTan(getLength(), pos, null);
296            }
297            float length = mTimeToLengthConversion * ((float) (time - mStartTime));
298            return mPathMeasure.getPosTan(length, pos, null);
299        }
300
301        boolean hasPointForTime(long time) {
302            return ((time >= mStartTime) && (time <= mEndTime));
303        }
304    }
305
306    private static class TouchPoint {
307        int mPathIndex;
308        boolean mIsStartOfPath;
309        boolean mIsEndOfPath;
310        float mX;
311        float mY;
312
313        void copyFrom(TouchPoint other) {
314            mPathIndex = other.mPathIndex;
315            mIsStartOfPath = other.mIsStartOfPath;
316            mIsEndOfPath = other.mIsEndOfPath;
317            mX = other.mX;
318            mY = other.mY;
319        }
320    }
321
322    /**
323     * Class to convert a GestureDescription to a series of MotionEvents.
324     */
325    static class MotionEventGenerator {
326        /**
327         * Constants used to initialize all MotionEvents
328         */
329        private static final int EVENT_META_STATE = 0;
330        private static final int EVENT_BUTTON_STATE = 0;
331        private static final int EVENT_DEVICE_ID = 0;
332        private static final int EVENT_EDGE_FLAGS = 0;
333        private static final int EVENT_SOURCE = InputDevice.SOURCE_TOUCHSCREEN;
334        private static final int EVENT_FLAGS = 0;
335        private static final float EVENT_X_PRECISION = 1;
336        private static final float EVENT_Y_PRECISION = 1;
337
338        /* Lazily-created scratch memory for processing touches */
339        private static TouchPoint[] sCurrentTouchPoints;
340        private static TouchPoint[] sLastTouchPoints;
341        private static PointerCoords[] sPointerCoords;
342        private static PointerProperties[] sPointerProps;
343
344        static List<MotionEvent> getMotionEventsFromGestureDescription(
345                GestureDescription description, int sampleTimeMs) {
346            final List<MotionEvent> motionEvents = new ArrayList<>();
347
348            // Point data at each time we generate an event for
349            final TouchPoint[] currentTouchPoints =
350                    getCurrentTouchPoints(description.getStrokeCount());
351            // Point data sent in last touch event
352            int lastTouchPointSize = 0;
353            final TouchPoint[] lastTouchPoints =
354                    getLastTouchPoints(description.getStrokeCount());
355
356            /* Loop through each time slice where there are touch points */
357            long timeSinceGestureStart = 0;
358            long nextKeyPointTime = description.getNextKeyPointAtLeast(timeSinceGestureStart);
359            while (nextKeyPointTime >= 0) {
360                timeSinceGestureStart = (lastTouchPointSize == 0) ? nextKeyPointTime
361                        : Math.min(nextKeyPointTime, timeSinceGestureStart + sampleTimeMs);
362                int currentTouchPointSize = description.getPointsForTime(timeSinceGestureStart,
363                        currentTouchPoints);
364
365                appendMoveEventIfNeeded(motionEvents, lastTouchPoints, lastTouchPointSize,
366                        currentTouchPoints, currentTouchPointSize, timeSinceGestureStart);
367                lastTouchPointSize = appendUpEvents(motionEvents, lastTouchPoints,
368                        lastTouchPointSize, currentTouchPoints, currentTouchPointSize,
369                        timeSinceGestureStart);
370                lastTouchPointSize = appendDownEvents(motionEvents, lastTouchPoints,
371                        lastTouchPointSize, currentTouchPoints, currentTouchPointSize,
372                        timeSinceGestureStart);
373
374                /* Move to next time slice */
375                nextKeyPointTime = description.getNextKeyPointAtLeast(timeSinceGestureStart + 1);
376            }
377            return motionEvents;
378        }
379
380        private static TouchPoint[] getCurrentTouchPoints(int requiredCapacity) {
381            if ((sCurrentTouchPoints == null) || (sCurrentTouchPoints.length < requiredCapacity)) {
382                sCurrentTouchPoints = new TouchPoint[requiredCapacity];
383                for (int i = 0; i < requiredCapacity; i++) {
384                    sCurrentTouchPoints[i] = new TouchPoint();
385                }
386            }
387            return sCurrentTouchPoints;
388        }
389
390        private static TouchPoint[] getLastTouchPoints(int requiredCapacity) {
391            if ((sLastTouchPoints == null) || (sLastTouchPoints.length < requiredCapacity)) {
392                sLastTouchPoints = new TouchPoint[requiredCapacity];
393                for (int i = 0; i < requiredCapacity; i++) {
394                    sLastTouchPoints[i] = new TouchPoint();
395                }
396            }
397            return sLastTouchPoints;
398        }
399
400        private static PointerCoords[] getPointerCoords(int requiredCapacity) {
401            if ((sPointerCoords == null) || (sPointerCoords.length < requiredCapacity)) {
402                sPointerCoords = new PointerCoords[requiredCapacity];
403                for (int i = 0; i < requiredCapacity; i++) {
404                    sPointerCoords[i] = new PointerCoords();
405                }
406            }
407            return sPointerCoords;
408        }
409
410        private static PointerProperties[] getPointerProps(int requiredCapacity) {
411            if ((sPointerProps == null) || (sPointerProps.length < requiredCapacity)) {
412                sPointerProps = new PointerProperties[requiredCapacity];
413                for (int i = 0; i < requiredCapacity; i++) {
414                    sPointerProps[i] = new PointerProperties();
415                }
416            }
417            return sPointerProps;
418        }
419
420        private static void appendMoveEventIfNeeded(List<MotionEvent> motionEvents,
421                TouchPoint[] lastTouchPoints, int lastTouchPointsSize,
422                TouchPoint[] currentTouchPoints, int currentTouchPointsSize, long currentTime) {
423            /* Look for pointers that have moved */
424            boolean moveFound = false;
425            for (int i = 0; i < currentTouchPointsSize; i++) {
426                int lastPointsIndex = findPointByPathIndex(lastTouchPoints, lastTouchPointsSize,
427                        currentTouchPoints[i].mPathIndex);
428                if (lastPointsIndex >= 0) {
429                    moveFound |= (lastTouchPoints[lastPointsIndex].mX != currentTouchPoints[i].mX)
430                            || (lastTouchPoints[lastPointsIndex].mY != currentTouchPoints[i].mY);
431                    lastTouchPoints[lastPointsIndex].copyFrom(currentTouchPoints[i]);
432                }
433            }
434
435            if (moveFound) {
436                long downTime = motionEvents.get(motionEvents.size() - 1).getDownTime();
437                motionEvents.add(obtainMotionEvent(downTime, currentTime, MotionEvent.ACTION_MOVE,
438                        lastTouchPoints, lastTouchPointsSize));
439            }
440        }
441
442        private static int appendUpEvents(List<MotionEvent> motionEvents,
443                TouchPoint[] lastTouchPoints, int lastTouchPointsSize,
444                TouchPoint[] currentTouchPoints, int currentTouchPointsSize, long currentTime) {
445            /* Look for a pointer at the end of its path */
446            for (int i = 0; i < currentTouchPointsSize; i++) {
447                if (currentTouchPoints[i].mIsEndOfPath) {
448                    int indexOfUpEvent = findPointByPathIndex(lastTouchPoints, lastTouchPointsSize,
449                            currentTouchPoints[i].mPathIndex);
450                    if (indexOfUpEvent < 0) {
451                        continue; // Should not happen
452                    }
453                    long downTime = motionEvents.get(motionEvents.size() - 1).getDownTime();
454                    int action = (lastTouchPointsSize == 1) ? MotionEvent.ACTION_UP
455                            : MotionEvent.ACTION_POINTER_UP;
456                    action |= indexOfUpEvent << MotionEvent.ACTION_POINTER_INDEX_SHIFT;
457                    motionEvents.add(obtainMotionEvent(downTime, currentTime, action,
458                            lastTouchPoints, lastTouchPointsSize));
459                    /* Remove this point from lastTouchPoints */
460                    for (int j = indexOfUpEvent; j < lastTouchPointsSize - 1; j++) {
461                        lastTouchPoints[j].copyFrom(lastTouchPoints[j+1]);
462                    }
463                    lastTouchPointsSize--;
464                }
465            }
466            return lastTouchPointsSize;
467        }
468
469        private static int appendDownEvents(List<MotionEvent> motionEvents,
470                TouchPoint[] lastTouchPoints, int lastTouchPointsSize,
471                TouchPoint[] currentTouchPoints, int currentTouchPointsSize, long currentTime) {
472            /* Look for a pointer that is just starting */
473            for (int i = 0; i < currentTouchPointsSize; i++) {
474                if (currentTouchPoints[i].mIsStartOfPath) {
475                    /* Add the point to last coords and use the new array to generate the event */
476                    lastTouchPoints[lastTouchPointsSize++].copyFrom(currentTouchPoints[i]);
477                    int action = (lastTouchPointsSize == 1) ? MotionEvent.ACTION_DOWN
478                            : MotionEvent.ACTION_POINTER_DOWN;
479                    long downTime = (action == MotionEvent.ACTION_DOWN) ? currentTime :
480                            motionEvents.get(motionEvents.size() - 1).getDownTime();
481                    action |= i << MotionEvent.ACTION_POINTER_INDEX_SHIFT;
482                    motionEvents.add(obtainMotionEvent(downTime, currentTime, action,
483                            lastTouchPoints, lastTouchPointsSize));
484                }
485            }
486            return lastTouchPointsSize;
487        }
488
489        private static MotionEvent obtainMotionEvent(long downTime, long eventTime, int action,
490                TouchPoint[] touchPoints, int touchPointsSize) {
491            PointerCoords[] pointerCoords = getPointerCoords(touchPointsSize);
492            PointerProperties[] pointerProperties = getPointerProps(touchPointsSize);
493            for (int i = 0; i < touchPointsSize; i++) {
494                pointerProperties[i].id = touchPoints[i].mPathIndex;
495                pointerProperties[i].toolType = MotionEvent.TOOL_TYPE_UNKNOWN;
496                pointerCoords[i].clear();
497                pointerCoords[i].pressure = 1.0f;
498                pointerCoords[i].size = 1.0f;
499                pointerCoords[i].x = touchPoints[i].mX;
500                pointerCoords[i].y = touchPoints[i].mY;
501            }
502            return MotionEvent.obtain(downTime, eventTime, action, touchPointsSize,
503                    pointerProperties, pointerCoords, EVENT_META_STATE, EVENT_BUTTON_STATE,
504                    EVENT_X_PRECISION, EVENT_Y_PRECISION, EVENT_DEVICE_ID, EVENT_EDGE_FLAGS,
505                    EVENT_SOURCE, EVENT_FLAGS);
506        }
507
508        private static int findPointByPathIndex(TouchPoint[] touchPoints, int touchPointsSize,
509                int pathIndex) {
510            for (int i = 0; i < touchPointsSize; i++) {
511                if (touchPoints[i].mPathIndex == pathIndex) {
512                    return i;
513                }
514            }
515            return -1;
516        }
517    }
518}
519