1 /* 2 * Copyright (C) 2019 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 package com.android.quickstep.util; 17 18 import static com.android.launcher3.testing.shared.TestProtocol.PAUSE_DETECTED_MESSAGE; 19 20 import android.content.Context; 21 import android.content.res.Resources; 22 import android.util.Log; 23 import android.view.MotionEvent; 24 import android.view.VelocityTracker; 25 26 import com.android.launcher3.Alarm; 27 import com.android.launcher3.R; 28 import com.android.launcher3.Utilities; 29 import com.android.launcher3.compat.AccessibilityManagerCompat; 30 31 /** 32 * Given positions along x- or y-axis, tracks velocity and acceleration and determines when there is 33 * a pause in motion. 34 */ 35 public class MotionPauseDetector { 36 37 private static final String TAG = "MotionPauseDetector"; 38 39 // The percentage of the previous speed that determines whether this is a rapid deceleration. 40 // The bigger this number, the easier it is to trigger the first pause. 41 private static final float RAPID_DECELERATION_FACTOR = 0.6f; 42 43 /** If no motion is added for this amount of time, assume the motion has paused. */ 44 private static final long FORCE_PAUSE_TIMEOUT = 300; 45 46 /** 47 * After {@link #mMakePauseHarderToTrigger}, must move slowly for this long to trigger a pause. 48 */ 49 private static final long HARDER_TRIGGER_TIMEOUT = 400; 50 51 /** 52 * When running in a test harness, if no motion is added for this amount of time, assume the 53 * motion has paused. (We use an increased timeout since sometimes test devices can be slow.) 54 */ 55 private static final long TEST_HARNESS_TRIGGER_TIMEOUT = 2000; 56 57 private final float mSpeedVerySlow; 58 private final float mSpeedSlow; 59 private final float mSpeedSomewhatFast; 60 private final float mSpeedFast; 61 private final Alarm mForcePauseTimeout; 62 private final boolean mMakePauseHarderToTrigger; 63 private final Context mContext; 64 private final SystemVelocityProvider mVelocityProvider; 65 66 private Float mPreviousVelocity = null; 67 68 private OnMotionPauseListener mOnMotionPauseListener; 69 private boolean mIsPaused; 70 // Bias more for the first pause to make it feel extra responsive. 71 private boolean mHasEverBeenPaused; 72 /** @see #setDisallowPause(boolean) */ 73 private boolean mDisallowPause; 74 // Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true). 75 private long mSlowStartTime; 76 MotionPauseDetector(Context context)77 public MotionPauseDetector(Context context) { 78 this(context, false); 79 } 80 81 /** 82 * @param makePauseHarderToTrigger Used for gestures that require a more explicit pause. 83 */ MotionPauseDetector(Context context, boolean makePauseHarderToTrigger)84 public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger) { 85 this(context, makePauseHarderToTrigger, MotionEvent.AXIS_Y); 86 } 87 88 /** 89 * @param makePauseHarderToTrigger Used for gestures that require a more explicit pause. 90 */ MotionPauseDetector(Context context, boolean makePauseHarderToTrigger, int axis)91 public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger, int axis) { 92 mContext = context; 93 Resources res = context.getResources(); 94 mSpeedVerySlow = res.getDimension(R.dimen.motion_pause_detector_speed_very_slow); 95 mSpeedSlow = res.getDimension(R.dimen.motion_pause_detector_speed_slow); 96 mSpeedSomewhatFast = res.getDimension(R.dimen.motion_pause_detector_speed_somewhat_fast); 97 mSpeedFast = res.getDimension(R.dimen.motion_pause_detector_speed_fast); 98 mForcePauseTimeout = new Alarm(); 99 mForcePauseTimeout.setOnAlarmListener(alarm -> { 100 ActiveGestureLog.CompoundString log = 101 new ActiveGestureLog.CompoundString("Force pause timeout after ") 102 .append(alarm.getLastSetTimeout()) 103 .append("ms"); 104 addLogs(log); 105 updatePaused(true /* isPaused */, log); 106 }); 107 mMakePauseHarderToTrigger = makePauseHarderToTrigger; 108 mVelocityProvider = new SystemVelocityProvider(axis); 109 } 110 111 /** 112 * Get callbacks for when motion pauses and resumes. 113 */ setOnMotionPauseListener(OnMotionPauseListener listener)114 public void setOnMotionPauseListener(OnMotionPauseListener listener) { 115 mOnMotionPauseListener = listener; 116 } 117 118 /** 119 * @param disallowPause If true, we will not detect any pauses until this is set to false again. 120 */ setDisallowPause(boolean disallowPause)121 public void setDisallowPause(boolean disallowPause) { 122 ActiveGestureLog.CompoundString log = 123 new ActiveGestureLog.CompoundString("Set disallowPause=") 124 .append(disallowPause); 125 if (mDisallowPause != disallowPause) { 126 addLogs(log); 127 } 128 mDisallowPause = disallowPause; 129 updatePaused(mIsPaused, log); 130 } 131 132 /** 133 * Computes velocity and acceleration to determine whether the motion is paused. 134 * @param ev The motion being tracked. 135 */ addPosition(MotionEvent ev)136 public void addPosition(MotionEvent ev) { 137 addPosition(ev, 0); 138 } 139 140 /** 141 * Computes velocity and acceleration to determine whether the motion is paused. 142 * @param ev The motion being tracked. 143 * @param pointerIndex Index for the pointer being tracked in the motion event 144 */ addPosition(MotionEvent ev, int pointerIndex)145 public void addPosition(MotionEvent ev, int pointerIndex) { 146 long timeoutMs = Utilities.isRunningInTestHarness() 147 ? TEST_HARNESS_TRIGGER_TIMEOUT 148 : mMakePauseHarderToTrigger 149 ? HARDER_TRIGGER_TIMEOUT 150 : FORCE_PAUSE_TIMEOUT; 151 mForcePauseTimeout.setAlarm(timeoutMs); 152 float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex)); 153 if (mPreviousVelocity != null) { 154 checkMotionPaused(newVelocity, mPreviousVelocity, ev.getEventTime()); 155 } 156 mPreviousVelocity = newVelocity; 157 } 158 checkMotionPaused(float velocity, float prevVelocity, long time)159 private void checkMotionPaused(float velocity, float prevVelocity, long time) { 160 float speed = Math.abs(velocity); 161 float previousSpeed = Math.abs(prevVelocity); 162 boolean isPaused; 163 ActiveGestureLog.CompoundString isPausedReason; 164 if (mIsPaused) { 165 // Continue to be paused until moving at a fast speed. 166 isPaused = speed < mSpeedFast || previousSpeed < mSpeedFast; 167 isPausedReason = new ActiveGestureLog.CompoundString( 168 "Was paused, but started moving at a fast speed"); 169 } else { 170 if (velocity < 0 != prevVelocity < 0) { 171 // We're just changing directions, not necessarily stopping. 172 isPaused = false; 173 isPausedReason = new ActiveGestureLog.CompoundString("Velocity changed directions"); 174 } else { 175 isPaused = speed < mSpeedVerySlow && previousSpeed < mSpeedVerySlow; 176 isPausedReason = new ActiveGestureLog.CompoundString( 177 "Pause requires back to back slow speeds"); 178 if (!isPaused && !mHasEverBeenPaused) { 179 // We want to be more aggressive about detecting the first pause to ensure it 180 // feels as responsive as possible; getting two very slow speeds back to back 181 // takes too long, so also check for a rapid deceleration. 182 boolean isRapidDeceleration = speed < previousSpeed * RAPID_DECELERATION_FACTOR; 183 isPaused = isRapidDeceleration && speed < mSpeedSomewhatFast; 184 isPausedReason = new ActiveGestureLog.CompoundString( 185 "Didn't have back to back slow speeds, checking for rapid ") 186 .append(" deceleration on first pause only"); 187 } 188 if (mMakePauseHarderToTrigger) { 189 if (speed < mSpeedSlow) { 190 if (mSlowStartTime == 0) { 191 mSlowStartTime = time; 192 } 193 isPaused = time - mSlowStartTime >= HARDER_TRIGGER_TIMEOUT; 194 isPausedReason = new ActiveGestureLog.CompoundString( 195 "Maintained slow speed for sufficient duration when making") 196 .append(" pause harder to trigger"); 197 } else { 198 mSlowStartTime = 0; 199 isPaused = false; 200 isPausedReason = new ActiveGestureLog.CompoundString( 201 "Intentionally making pause harder to trigger"); 202 } 203 } 204 } 205 } 206 updatePaused(isPaused, isPausedReason); 207 } 208 209 private void updatePaused(boolean isPaused, ActiveGestureLog.CompoundString reason) { 210 if (mDisallowPause) { 211 reason = new ActiveGestureLog.CompoundString( 212 "Disallow pause; otherwise, would have been ") 213 .append(isPaused) 214 .append(" due to reason:") 215 .append(reason); 216 isPaused = false; 217 } 218 if (mIsPaused != isPaused) { 219 mIsPaused = isPaused; 220 addLogs(new ActiveGestureLog.CompoundString("onMotionPauseChanged triggered; paused=") 221 .append(mIsPaused) 222 .append(", reason=") 223 .append(reason)); 224 boolean isFirstDetectedPause = !mHasEverBeenPaused && mIsPaused; 225 if (mIsPaused) { 226 AccessibilityManagerCompat.sendTestProtocolEventToTest(mContext, 227 PAUSE_DETECTED_MESSAGE); 228 mHasEverBeenPaused = true; 229 } 230 if (mOnMotionPauseListener != null) { 231 if (isFirstDetectedPause) { 232 mOnMotionPauseListener.onMotionPauseDetected(); 233 } 234 // Null check again as onMotionPauseDetected() maybe have called clear(). 235 if (mOnMotionPauseListener != null) { 236 mOnMotionPauseListener.onMotionPauseChanged(mIsPaused); 237 } 238 } 239 } 240 } 241 242 private void addLogs(ActiveGestureLog.CompoundString compoundString) { 243 ActiveGestureLog.CompoundString logString = 244 new ActiveGestureLog.CompoundString("MotionPauseDetector: ") 245 .append(compoundString); 246 if (Utilities.isRunningInTestHarness()) { 247 Log.d(TAG, logString.toString()); 248 } 249 ActiveGestureLog.INSTANCE.addLog(logString); 250 } 251 252 public void clear() { 253 mVelocityProvider.clear(); 254 mPreviousVelocity = null; 255 setOnMotionPauseListener(null); 256 mIsPaused = mHasEverBeenPaused = false; 257 mSlowStartTime = 0; 258 mForcePauseTimeout.cancelAlarm(); 259 } 260 261 public boolean isPaused() { 262 return mIsPaused; 263 } 264 265 public interface OnMotionPauseListener { 266 /** Called only the first time motion pause is detected. */ 267 void onMotionPauseDetected(); 268 /** Called every time motion changes from paused to not paused and vice versa. */ 269 default void onMotionPauseChanged(boolean isPaused) { } 270 } 271 272 private static class SystemVelocityProvider { 273 274 private final VelocityTracker mVelocityTracker; 275 private final int mAxis; 276 277 SystemVelocityProvider(int axis) { 278 mVelocityTracker = VelocityTracker.obtain(); 279 mAxis = axis; 280 } 281 282 /** 283 * Adds a new motion events, and returns the velocity at this point, or null if 284 * the velocity is not available 285 */ 286 public float addMotionEvent(MotionEvent ev, int pointer) { 287 mVelocityTracker.addMovement(ev); 288 mVelocityTracker.computeCurrentVelocity(1); // px / ms 289 return mAxis == MotionEvent.AXIS_X 290 ? mVelocityTracker.getXVelocity(pointer) 291 : mVelocityTracker.getYVelocity(pointer); 292 } 293 294 /** 295 * Clears all stored motion event records 296 */ 297 public void clear() { 298 mVelocityTracker.clear(); 299 } 300 } 301 } 302