Home | History | Annotate | Download | only in systemui
      1 /*
      2  * Copyright (C) 2012 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 
     17 
     18 package com.android.systemui;
     19 
     20 import android.animation.Animator;
     21 import android.animation.AnimatorListenerAdapter;
     22 import android.animation.ObjectAnimator;
     23 import android.content.Context;
     24 import android.media.AudioAttributes;
     25 import android.media.AudioManager;
     26 import android.os.Vibrator;
     27 import android.util.Log;
     28 import android.view.Gravity;
     29 import android.view.MotionEvent;
     30 import android.view.ScaleGestureDetector;
     31 import android.view.ScaleGestureDetector.OnScaleGestureListener;
     32 import android.view.VelocityTracker;
     33 import android.view.View;
     34 import android.view.ViewConfiguration;
     35 
     36 import com.android.systemui.statusbar.ExpandableNotificationRow;
     37 import com.android.systemui.statusbar.ExpandableView;
     38 import com.android.systemui.statusbar.FlingAnimationUtils;
     39 import com.android.systemui.statusbar.policy.ScrollAdapter;
     40 
     41 public class ExpandHelper implements Gefingerpoken {
     42     public interface Callback {
     43         ExpandableView getChildAtRawPosition(float x, float y);
     44         ExpandableView getChildAtPosition(float x, float y);
     45         boolean canChildBeExpanded(View v);
     46         void setUserExpandedChild(View v, boolean userExpanded);
     47         void setUserLockedChild(View v, boolean userLocked);
     48         void expansionStateChanged(boolean isExpanding);
     49     }
     50 
     51     private static final String TAG = "ExpandHelper";
     52     protected static final boolean DEBUG = false;
     53     protected static final boolean DEBUG_SCALE = false;
     54     private static final float EXPAND_DURATION = 0.3f;
     55 
     56     // Set to false to disable focus-based gestures (spread-finger vertical pull).
     57     private static final boolean USE_DRAG = true;
     58     // Set to false to disable scale-based gestures (both horizontal and vertical).
     59     private static final boolean USE_SPAN = true;
     60     // Both gestures types may be active at the same time.
     61     // At least one gesture type should be active.
     62     // A variant of the screwdriver gesture will emerge from either gesture type.
     63 
     64     // amount of overstretch for maximum brightness expressed in U
     65     // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U
     66     private static final float STRETCH_INTERVAL = 2f;
     67 
     68     // level of glow for a touch, without overstretch
     69     // overstretch fills the range (GLOW_BASE, 1.0]
     70     private static final float GLOW_BASE = 0.5f;
     71 
     72     private static final AudioAttributes VIBRATION_ATTRIBUTES = new AudioAttributes.Builder()
     73             .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
     74             .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
     75             .build();
     76 
     77     @SuppressWarnings("unused")
     78     private Context mContext;
     79 
     80     private boolean mExpanding;
     81     private static final int NONE    = 0;
     82     private static final int BLINDS  = 1<<0;
     83     private static final int PULL    = 1<<1;
     84     private static final int STRETCH = 1<<2;
     85     private int mExpansionStyle = NONE;
     86     private boolean mWatchingForPull;
     87     private boolean mHasPopped;
     88     private View mEventSource;
     89     private float mOldHeight;
     90     private float mNaturalHeight;
     91     private float mInitialTouchFocusY;
     92     private float mInitialTouchY;
     93     private float mInitialTouchSpan;
     94     private float mLastFocusY;
     95     private float mLastSpanY;
     96     private int mTouchSlop;
     97     private float mLastMotionY;
     98     private int mPopDuration;
     99     private float mPullGestureMinXSpan;
    100     private Callback mCallback;
    101     private ScaleGestureDetector mSGD;
    102     private ViewScaler mScaler;
    103     private ObjectAnimator mScaleAnimation;
    104     private Vibrator mVibrator;
    105     private boolean mEnabled = true;
    106     private ExpandableView mResizedView;
    107     private float mCurrentHeight;
    108 
    109     private int mSmallSize;
    110     private int mLargeSize;
    111     private float mMaximumStretch;
    112     private boolean mOnlyMovements;
    113 
    114     private int mGravity;
    115 
    116     private ScrollAdapter mScrollAdapter;
    117     private FlingAnimationUtils mFlingAnimationUtils;
    118     private VelocityTracker mVelocityTracker;
    119 
    120     private OnScaleGestureListener mScaleGestureListener
    121             = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
    122         @Override
    123         public boolean onScaleBegin(ScaleGestureDetector detector) {
    124             if (DEBUG_SCALE) Log.v(TAG, "onscalebegin()");
    125 
    126             startExpanding(mResizedView, STRETCH);
    127             return mExpanding;
    128         }
    129 
    130         @Override
    131         public boolean onScale(ScaleGestureDetector detector) {
    132             if (DEBUG_SCALE) Log.v(TAG, "onscale() on " + mResizedView);
    133             return true;
    134         }
    135 
    136         @Override
    137         public void onScaleEnd(ScaleGestureDetector detector) {
    138         }
    139     };
    140 
    141     private class ViewScaler {
    142         ExpandableView mView;
    143 
    144         public ViewScaler() {}
    145         public void setView(ExpandableView v) {
    146             mView = v;
    147         }
    148         public void setHeight(float h) {
    149             if (DEBUG_SCALE) Log.v(TAG, "SetHeight: setting to " + h);
    150             mView.setActualHeight((int) h);
    151             mCurrentHeight = h;
    152         }
    153         public float getHeight() {
    154             return mView.getActualHeight();
    155         }
    156         public int getNaturalHeight(int maximum) {
    157             return Math.min(maximum, mView.getMaxHeight());
    158         }
    159     }
    160 
    161     /**
    162      * Handle expansion gestures to expand and contract children of the callback.
    163      *
    164      * @param context application context
    165      * @param callback the container that holds the items to be manipulated
    166      * @param small the smallest allowable size for the manuipulated items.
    167      * @param large the largest allowable size for the manuipulated items.
    168      */
    169     public ExpandHelper(Context context, Callback callback, int small, int large) {
    170         mSmallSize = small;
    171         mMaximumStretch = mSmallSize * STRETCH_INTERVAL;
    172         mLargeSize = large;
    173         mContext = context;
    174         mCallback = callback;
    175         mScaler = new ViewScaler();
    176         mGravity = Gravity.TOP;
    177         mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f);
    178         mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms);
    179         mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min);
    180 
    181         final ViewConfiguration configuration = ViewConfiguration.get(mContext);
    182         mTouchSlop = configuration.getScaledTouchSlop();
    183 
    184         mSGD = new ScaleGestureDetector(context, mScaleGestureListener);
    185         mFlingAnimationUtils = new FlingAnimationUtils(context, EXPAND_DURATION);
    186     }
    187 
    188     private void updateExpansion() {
    189         if (DEBUG_SCALE) Log.v(TAG, "updateExpansion()");
    190         // are we scaling or dragging?
    191         float span = mSGD.getCurrentSpan() - mInitialTouchSpan;
    192         span *= USE_SPAN ? 1f : 0f;
    193         float drag = mSGD.getFocusY() - mInitialTouchFocusY;
    194         drag *= USE_DRAG ? 1f : 0f;
    195         drag *= mGravity == Gravity.BOTTOM ? -1f : 1f;
    196         float pull = Math.abs(drag) + Math.abs(span) + 1f;
    197         float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull;
    198         float target = hand + mOldHeight;
    199         float newHeight = clamp(target);
    200         mScaler.setHeight(newHeight);
    201         mLastFocusY = mSGD.getFocusY();
    202         mLastSpanY = mSGD.getCurrentSpan();
    203     }
    204 
    205     private float clamp(float target) {
    206         float out = target;
    207         out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out);
    208         out = out > mNaturalHeight ? mNaturalHeight : out;
    209         return out;
    210     }
    211 
    212     private ExpandableView findView(float x, float y) {
    213         ExpandableView v;
    214         if (mEventSource != null) {
    215             int[] location = new int[2];
    216             mEventSource.getLocationOnScreen(location);
    217             x += location[0];
    218             y += location[1];
    219             v = mCallback.getChildAtRawPosition(x, y);
    220         } else {
    221             v = mCallback.getChildAtPosition(x, y);
    222         }
    223         return v;
    224     }
    225 
    226     private boolean isInside(View v, float x, float y) {
    227         if (DEBUG) Log.d(TAG, "isinside (" + x + ", " + y + ")");
    228 
    229         if (v == null) {
    230             if (DEBUG) Log.d(TAG, "isinside null subject");
    231             return false;
    232         }
    233         if (mEventSource != null) {
    234             int[] location = new int[2];
    235             mEventSource.getLocationOnScreen(location);
    236             x += location[0];
    237             y += location[1];
    238             if (DEBUG) Log.d(TAG, "  to global (" + x + ", " + y + ")");
    239         }
    240         int[] location = new int[2];
    241         v.getLocationOnScreen(location);
    242         x -= location[0];
    243         y -= location[1];
    244         if (DEBUG) Log.d(TAG, "  to local (" + x + ", " + y + ")");
    245         if (DEBUG) Log.d(TAG, "  inside (" + v.getWidth() + ", " + v.getHeight() + ")");
    246         boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight());
    247         return inside;
    248     }
    249 
    250     public void setEventSource(View eventSource) {
    251         mEventSource = eventSource;
    252     }
    253 
    254     public void setGravity(int gravity) {
    255         mGravity = gravity;
    256     }
    257 
    258     public void setScrollAdapter(ScrollAdapter adapter) {
    259         mScrollAdapter = adapter;
    260     }
    261 
    262     @Override
    263     public boolean onInterceptTouchEvent(MotionEvent ev) {
    264         if (!isEnabled()) {
    265             return false;
    266         }
    267         trackVelocity(ev);
    268         final int action = ev.getAction();
    269         if (DEBUG_SCALE) Log.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) +
    270                          " expanding=" + mExpanding +
    271                          (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
    272                          (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
    273                          (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
    274         // check for a spread-finger vertical pull gesture
    275         mSGD.onTouchEvent(ev);
    276         final int x = (int) mSGD.getFocusX();
    277         final int y = (int) mSGD.getFocusY();
    278 
    279         mInitialTouchFocusY = y;
    280         mInitialTouchSpan = mSGD.getCurrentSpan();
    281         mLastFocusY = mInitialTouchFocusY;
    282         mLastSpanY = mInitialTouchSpan;
    283         if (DEBUG_SCALE) Log.d(TAG, "set initial span: " + mInitialTouchSpan);
    284 
    285         if (mExpanding) {
    286             mLastMotionY = ev.getRawY();
    287             maybeRecycleVelocityTracker(ev);
    288             return true;
    289         } else {
    290             if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) {
    291                 // we've begun Venetian blinds style expansion
    292                 return true;
    293             }
    294             switch (action & MotionEvent.ACTION_MASK) {
    295             case MotionEvent.ACTION_MOVE: {
    296                 final float xspan = mSGD.getCurrentSpanX();
    297                 if (xspan > mPullGestureMinXSpan &&
    298                         xspan > mSGD.getCurrentSpanY() && !mExpanding) {
    299                     // detect a vertical pulling gesture with fingers somewhat separated
    300                     if (DEBUG_SCALE) Log.v(TAG, "got pull gesture (xspan=" + xspan + "px)");
    301                     startExpanding(mResizedView, PULL);
    302                     mWatchingForPull = false;
    303                 }
    304                 if (mWatchingForPull) {
    305                     final float yDiff = ev.getRawY() - mInitialTouchY;
    306                     if (yDiff > mTouchSlop) {
    307                         if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
    308                         mWatchingForPull = false;
    309                         if (mResizedView != null && !isFullyExpanded(mResizedView)) {
    310                             if (startExpanding(mResizedView, BLINDS)) {
    311                                 mLastMotionY = ev.getRawY();
    312                                 mInitialTouchY = ev.getRawY();
    313                                 mHasPopped = false;
    314                             }
    315                         }
    316                     }
    317                 }
    318                 break;
    319             }
    320 
    321             case MotionEvent.ACTION_DOWN:
    322                 mWatchingForPull = mScrollAdapter != null &&
    323                         isInside(mScrollAdapter.getHostView(), x, y)
    324                         && mScrollAdapter.isScrolledToTop();
    325                 mResizedView = findView(x, y);
    326                 mInitialTouchY = ev.getY();
    327                 break;
    328 
    329             case MotionEvent.ACTION_CANCEL:
    330             case MotionEvent.ACTION_UP:
    331                 if (DEBUG) Log.d(TAG, "up/cancel");
    332                 finishExpanding(false, getCurrentVelocity());
    333                 clearView();
    334                 break;
    335             }
    336             mLastMotionY = ev.getRawY();
    337             maybeRecycleVelocityTracker(ev);
    338             return mExpanding;
    339         }
    340     }
    341 
    342     private void trackVelocity(MotionEvent event) {
    343         int action = event.getActionMasked();
    344         switch(action) {
    345             case MotionEvent.ACTION_DOWN:
    346                 if (mVelocityTracker == null) {
    347                     mVelocityTracker = VelocityTracker.obtain();
    348                 } else {
    349                     mVelocityTracker.clear();
    350                 }
    351                 mVelocityTracker.addMovement(event);
    352                 break;
    353             case MotionEvent.ACTION_MOVE:
    354                 if (mVelocityTracker == null) {
    355                     mVelocityTracker = VelocityTracker.obtain();
    356                 }
    357                 mVelocityTracker.addMovement(event);
    358                 break;
    359             default:
    360                 break;
    361         }
    362     }
    363 
    364     private void maybeRecycleVelocityTracker(MotionEvent event) {
    365         if (mVelocityTracker != null && (event.getActionMasked() == MotionEvent.ACTION_CANCEL
    366                 || event.getActionMasked() == MotionEvent.ACTION_UP)) {
    367             mVelocityTracker.recycle();
    368             mVelocityTracker = null;
    369         }
    370     }
    371 
    372     private float getCurrentVelocity() {
    373         if (mVelocityTracker != null) {
    374             mVelocityTracker.computeCurrentVelocity(1000);
    375             return mVelocityTracker.getYVelocity();
    376         } else {
    377             return 0f;
    378         }
    379     }
    380 
    381     public void setEnabled(boolean enable) {
    382         mEnabled = enable;
    383     }
    384 
    385     private boolean isEnabled() {
    386         return mEnabled;
    387     }
    388 
    389     private boolean isFullyExpanded(ExpandableView underFocus) {
    390         return underFocus.getIntrinsicHeight() == underFocus.getMaxHeight();
    391     }
    392 
    393     @Override
    394     public boolean onTouchEvent(MotionEvent ev) {
    395         if (!isEnabled()) {
    396             return false;
    397         }
    398         trackVelocity(ev);
    399         final int action = ev.getActionMasked();
    400         if (DEBUG_SCALE) Log.d(TAG, "touch: act=" + MotionEvent.actionToString(action) +
    401                 " expanding=" + mExpanding +
    402                 (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") +
    403                 (0 != (mExpansionStyle & PULL) ? " (pull)" : "") +
    404                 (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : ""));
    405 
    406         mSGD.onTouchEvent(ev);
    407         final int x = (int) mSGD.getFocusX();
    408         final int y = (int) mSGD.getFocusY();
    409 
    410         if (mOnlyMovements) {
    411             mLastMotionY = ev.getRawY();
    412             return false;
    413         }
    414         switch (action) {
    415             case MotionEvent.ACTION_DOWN:
    416                 mWatchingForPull = mScrollAdapter != null &&
    417                         isInside(mScrollAdapter.getHostView(), x, y);
    418                 mResizedView = findView(x, y);
    419                 mInitialTouchY = ev.getY();
    420                 break;
    421             case MotionEvent.ACTION_MOVE: {
    422                 if (mWatchingForPull) {
    423                     final float yDiff = ev.getRawY() - mInitialTouchY;
    424                     if (yDiff > mTouchSlop) {
    425                         if (DEBUG) Log.v(TAG, "got venetian gesture (dy=" + yDiff + "px)");
    426                         mWatchingForPull = false;
    427                         if (mResizedView != null && !isFullyExpanded(mResizedView)) {
    428                             if (startExpanding(mResizedView, BLINDS)) {
    429                                 mInitialTouchY = ev.getRawY();
    430                                 mLastMotionY = ev.getRawY();
    431                                 mHasPopped = false;
    432                             }
    433                         }
    434                     }
    435                 }
    436                 if (mExpanding && 0 != (mExpansionStyle & BLINDS)) {
    437                     final float rawHeight = ev.getRawY() - mLastMotionY + mCurrentHeight;
    438                     final float newHeight = clamp(rawHeight);
    439                     boolean isFinished = false;
    440                     boolean expanded = false;
    441                     if (rawHeight > mNaturalHeight) {
    442                         isFinished = true;
    443                         expanded = true;
    444                     }
    445                     if (rawHeight < mSmallSize) {
    446                         isFinished = true;
    447                         expanded = false;
    448                     }
    449 
    450                     if (!mHasPopped) {
    451                         vibrate(mPopDuration);
    452                         mHasPopped = true;
    453                     }
    454 
    455                     mScaler.setHeight(newHeight);
    456                     mLastMotionY = ev.getRawY();
    457                     if (isFinished) {
    458                         mCallback.setUserExpandedChild(mResizedView, expanded);
    459                         mCallback.expansionStateChanged(false);
    460                         return false;
    461                     } else {
    462                         mCallback.expansionStateChanged(true);
    463                     }
    464                     return true;
    465                 }
    466 
    467                 if (mExpanding) {
    468 
    469                     // Gestural expansion is running
    470                     updateExpansion();
    471                     mLastMotionY = ev.getRawY();
    472                     return true;
    473                 }
    474 
    475                 break;
    476             }
    477 
    478             case MotionEvent.ACTION_POINTER_UP:
    479             case MotionEvent.ACTION_POINTER_DOWN:
    480                 if (DEBUG) Log.d(TAG, "pointer change");
    481                 mInitialTouchY += mSGD.getFocusY() - mLastFocusY;
    482                 mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY;
    483                 break;
    484 
    485             case MotionEvent.ACTION_UP:
    486             case MotionEvent.ACTION_CANCEL:
    487                 if (DEBUG) Log.d(TAG, "up/cancel");
    488                 finishExpanding(false, getCurrentVelocity());
    489                 clearView();
    490                 break;
    491         }
    492         mLastMotionY = ev.getRawY();
    493         maybeRecycleVelocityTracker(ev);
    494         return mResizedView != null;
    495     }
    496 
    497     /**
    498      * @return True if the view is expandable, false otherwise.
    499      */
    500     private boolean startExpanding(ExpandableView v, int expandType) {
    501         if (!(v instanceof ExpandableNotificationRow)) {
    502             return false;
    503         }
    504         mExpansionStyle = expandType;
    505         if (mExpanding && v == mResizedView) {
    506             return true;
    507         }
    508         mExpanding = true;
    509         mCallback.expansionStateChanged(true);
    510         if (DEBUG) Log.d(TAG, "scale type " + expandType + " beginning on view: " + v);
    511         mCallback.setUserLockedChild(v, true);
    512         mScaler.setView(v);
    513         mOldHeight = mScaler.getHeight();
    514         mCurrentHeight = mOldHeight;
    515         if (mCallback.canChildBeExpanded(v)) {
    516             if (DEBUG) Log.d(TAG, "working on an expandable child");
    517             mNaturalHeight = mScaler.getNaturalHeight(mLargeSize);
    518         } else {
    519             if (DEBUG) Log.d(TAG, "working on a non-expandable child");
    520             mNaturalHeight = mOldHeight;
    521         }
    522         if (DEBUG) Log.d(TAG, "got mOldHeight: " + mOldHeight +
    523                     " mNaturalHeight: " + mNaturalHeight);
    524         return true;
    525     }
    526 
    527     private void finishExpanding(boolean force, float velocity) {
    528         if (!mExpanding) return;
    529 
    530         if (DEBUG) Log.d(TAG, "scale in finishing on view: " + mResizedView);
    531 
    532         float currentHeight = mScaler.getHeight();
    533         float targetHeight = mSmallSize;
    534         float h = mScaler.getHeight();
    535         final boolean wasClosed = (mOldHeight == mSmallSize);
    536         if (wasClosed) {
    537             targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize;
    538         } else {
    539             targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight;
    540         }
    541         if (mScaleAnimation.isRunning()) {
    542             mScaleAnimation.cancel();
    543         }
    544         mCallback.setUserExpandedChild(mResizedView, targetHeight == mNaturalHeight);
    545         mCallback.expansionStateChanged(false);
    546         if (targetHeight != currentHeight) {
    547             mScaleAnimation.setFloatValues(targetHeight);
    548             mScaleAnimation.setupStartValues();
    549             final View scaledView = mResizedView;
    550             mScaleAnimation.addListener(new AnimatorListenerAdapter() {
    551                 @Override
    552                 public void onAnimationEnd(Animator animation) {
    553                     mCallback.setUserLockedChild(scaledView, false);
    554                     mScaleAnimation.removeListener(this);
    555                 }
    556             });
    557             mFlingAnimationUtils.apply(mScaleAnimation, currentHeight, targetHeight, velocity);
    558             mScaleAnimation.start();
    559         } else {
    560             mCallback.setUserLockedChild(mResizedView, false);
    561         }
    562 
    563         mExpanding = false;
    564         mExpansionStyle = NONE;
    565 
    566         if (DEBUG) Log.d(TAG, "wasClosed is: " + wasClosed);
    567         if (DEBUG) Log.d(TAG, "currentHeight is: " + currentHeight);
    568         if (DEBUG) Log.d(TAG, "mSmallSize is: " + mSmallSize);
    569         if (DEBUG) Log.d(TAG, "targetHeight is: " + targetHeight);
    570         if (DEBUG) Log.d(TAG, "scale was finished on view: " + mResizedView);
    571     }
    572 
    573     private void clearView() {
    574         mResizedView = null;
    575     }
    576 
    577     /**
    578      * Use this to abort any pending expansions in progress.
    579      */
    580     public void cancel() {
    581         finishExpanding(true, 0f /* velocity */);
    582         clearView();
    583 
    584         // reset the gesture detector
    585         mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener);
    586     }
    587 
    588     /**
    589      * Change the expansion mode to only observe movements and don't perform any resizing.
    590      * This is needed when the expanding is finished and the scroller kicks in,
    591      * performing an overscroll motion. We only want to shrink it again when we are not
    592      * overscrolled.
    593      *
    594      * @param onlyMovements Should only movements be observed?
    595      */
    596     public void onlyObserveMovements(boolean onlyMovements) {
    597         mOnlyMovements = onlyMovements;
    598     }
    599 
    600     /**
    601      * Triggers haptic feedback.
    602      */
    603     private synchronized void vibrate(long duration) {
    604         if (mVibrator == null) {
    605             mVibrator = (android.os.Vibrator)
    606                     mContext.getSystemService(Context.VIBRATOR_SERVICE);
    607         }
    608         mVibrator.vibrate(duration, VIBRATION_ATTRIBUTES);
    609     }
    610 }
    611 
    612