1/*
2 * Copyright (C) 2011 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 com.android.internal.widget.multiwaveview;
18
19import android.animation.Animator;
20import android.animation.Animator.AnimatorListener;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.TimeInterpolator;
23import android.animation.ValueAnimator;
24import android.animation.ValueAnimator.AnimatorUpdateListener;
25import android.content.ComponentName;
26import android.content.Context;
27import android.content.pm.PackageManager;
28import android.content.pm.PackageManager.NameNotFoundException;
29import android.content.res.Resources;
30import android.content.res.TypedArray;
31import android.graphics.Canvas;
32import android.graphics.RectF;
33import android.graphics.drawable.Drawable;
34import android.os.Bundle;
35import android.os.UserHandle;
36import android.os.Vibrator;
37import android.provider.Settings;
38import android.text.TextUtils;
39import android.util.AttributeSet;
40import android.util.Log;
41import android.util.TypedValue;
42import android.view.Gravity;
43import android.view.MotionEvent;
44import android.view.View;
45import android.view.accessibility.AccessibilityEvent;
46import android.view.accessibility.AccessibilityManager;
47
48import com.android.internal.R;
49
50import java.util.ArrayList;
51
52/**
53 * A special widget containing a center and outer ring. Moving the center ring to the outer ring
54 * causes an event that can be caught by implementing OnTriggerListener.
55 */
56public class MultiWaveView extends View {
57    private static final String TAG = "MultiWaveView";
58    private static final boolean DEBUG = false;
59
60    // Wave state machine
61    private static final int STATE_IDLE = 0;
62    private static final int STATE_START = 1;
63    private static final int STATE_FIRST_TOUCH = 2;
64    private static final int STATE_TRACKING = 3;
65    private static final int STATE_SNAP = 4;
66    private static final int STATE_FINISH = 5;
67
68    // Animation properties.
69    private static final float SNAP_MARGIN_DEFAULT = 20.0f; // distance to ring before we snap to it
70
71    public interface OnTriggerListener {
72        int NO_HANDLE = 0;
73        int CENTER_HANDLE = 1;
74        public void onGrabbed(View v, int handle);
75        public void onReleased(View v, int handle);
76        public void onTrigger(View v, int target);
77        public void onGrabbedStateChange(View v, int handle);
78        public void onFinishFinalAnimation();
79    }
80
81    // Tuneable parameters for animation
82    private static final int CHEVRON_INCREMENTAL_DELAY = 160;
83    private static final int CHEVRON_ANIMATION_DURATION = 850;
84    private static final int RETURN_TO_HOME_DELAY = 1200;
85    private static final int RETURN_TO_HOME_DURATION = 200;
86    private static final int HIDE_ANIMATION_DELAY = 200;
87    private static final int HIDE_ANIMATION_DURATION = 200;
88    private static final int SHOW_ANIMATION_DURATION = 200;
89    private static final int SHOW_ANIMATION_DELAY = 50;
90    private static final int INITIAL_SHOW_HANDLE_DURATION = 200;
91
92    private static final float TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED = 1.3f;
93    private static final float TARGET_SCALE_EXPANDED = 1.0f;
94    private static final float TARGET_SCALE_COLLAPSED = 0.8f;
95    private static final float RING_SCALE_EXPANDED = 1.0f;
96    private static final float RING_SCALE_COLLAPSED = 0.5f;
97
98    private TimeInterpolator mChevronAnimationInterpolator = Ease.Quad.easeOut;
99
100    private ArrayList<TargetDrawable> mTargetDrawables = new ArrayList<TargetDrawable>();
101    private ArrayList<TargetDrawable> mChevronDrawables = new ArrayList<TargetDrawable>();
102    private AnimationBundle mChevronAnimations = new AnimationBundle();
103    private AnimationBundle mTargetAnimations = new AnimationBundle();
104    private AnimationBundle mHandleAnimations = new AnimationBundle();
105    private ArrayList<String> mTargetDescriptions;
106    private ArrayList<String> mDirectionDescriptions;
107    private OnTriggerListener mOnTriggerListener;
108    private TargetDrawable mHandleDrawable;
109    private TargetDrawable mOuterRing;
110    private Vibrator mVibrator;
111
112    private int mFeedbackCount = 3;
113    private int mVibrationDuration = 0;
114    private int mGrabbedState;
115    private int mActiveTarget = -1;
116    private float mTapRadius;
117    private float mWaveCenterX;
118    private float mWaveCenterY;
119    private int mMaxTargetHeight;
120    private int mMaxTargetWidth;
121
122    private float mOuterRadius = 0.0f;
123    private float mSnapMargin = 0.0f;
124    private boolean mDragging;
125    private int mNewTargetResources;
126
127    private class AnimationBundle extends ArrayList<Tweener> {
128        private static final long serialVersionUID = 0xA84D78726F127468L;
129        private boolean mSuspended;
130
131        public void start() {
132            if (mSuspended) return; // ignore attempts to start animations
133            final int count = size();
134            for (int i = 0; i < count; i++) {
135                Tweener anim = get(i);
136                anim.animator.start();
137            }
138        }
139
140        public void cancel() {
141            final int count = size();
142            for (int i = 0; i < count; i++) {
143                Tweener anim = get(i);
144                anim.animator.cancel();
145            }
146            clear();
147        }
148
149        public void stop() {
150            final int count = size();
151            for (int i = 0; i < count; i++) {
152                Tweener anim = get(i);
153                anim.animator.end();
154            }
155            clear();
156        }
157
158        public void setSuspended(boolean suspend) {
159            mSuspended = suspend;
160        }
161    };
162
163    private AnimatorListener mResetListener = new AnimatorListenerAdapter() {
164        public void onAnimationEnd(Animator animator) {
165            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
166            dispatchOnFinishFinalAnimation();
167        }
168    };
169
170    private AnimatorListener mResetListenerWithPing = new AnimatorListenerAdapter() {
171        public void onAnimationEnd(Animator animator) {
172            ping();
173            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
174            dispatchOnFinishFinalAnimation();
175        }
176    };
177
178    private AnimatorUpdateListener mUpdateListener = new AnimatorUpdateListener() {
179        public void onAnimationUpdate(ValueAnimator animation) {
180            invalidateGlobalRegion(mHandleDrawable);
181            invalidate();
182        }
183    };
184
185    private boolean mAnimatingTargets;
186    private AnimatorListener mTargetUpdateListener = new AnimatorListenerAdapter() {
187        public void onAnimationEnd(Animator animator) {
188            if (mNewTargetResources != 0) {
189                internalSetTargetResources(mNewTargetResources);
190                mNewTargetResources = 0;
191                hideTargets(false, false);
192            }
193            mAnimatingTargets = false;
194        }
195    };
196    private int mTargetResourceId;
197    private int mTargetDescriptionsResourceId;
198    private int mDirectionDescriptionsResourceId;
199    private boolean mAlwaysTrackFinger;
200    private int mHorizontalInset;
201    private int mVerticalInset;
202    private int mGravity = Gravity.TOP;
203    private boolean mInitialLayout = true;
204    private Tweener mBackgroundAnimator;
205
206    public MultiWaveView(Context context) {
207        this(context, null);
208    }
209
210    public MultiWaveView(Context context, AttributeSet attrs) {
211        super(context, attrs);
212        Resources res = context.getResources();
213
214        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiWaveView);
215        mOuterRadius = a.getDimension(R.styleable.MultiWaveView_outerRadius, mOuterRadius);
216        mSnapMargin = a.getDimension(R.styleable.MultiWaveView_snapMargin, mSnapMargin);
217        mVibrationDuration = a.getInt(R.styleable.MultiWaveView_vibrationDuration,
218                mVibrationDuration);
219        mFeedbackCount = a.getInt(R.styleable.MultiWaveView_feedbackCount,
220                mFeedbackCount);
221        mHandleDrawable = new TargetDrawable(res,
222                a.peekValue(R.styleable.MultiWaveView_handleDrawable).resourceId);
223        mTapRadius = mHandleDrawable.getWidth()/2;
224        mOuterRing = new TargetDrawable(res,
225                a.peekValue(R.styleable.MultiWaveView_waveDrawable).resourceId);
226        mAlwaysTrackFinger = a.getBoolean(R.styleable.MultiWaveView_alwaysTrackFinger, false);
227
228        // Read array of chevron drawables
229        TypedValue outValue = new TypedValue();
230        if (a.getValue(R.styleable.MultiWaveView_chevronDrawables, outValue)) {
231            ArrayList<TargetDrawable> chevrons = loadDrawableArray(outValue.resourceId);
232            for (int i = 0; i < chevrons.size(); i++) {
233                final TargetDrawable chevron = chevrons.get(i);
234                for (int k = 0; k < mFeedbackCount; k++) {
235                    mChevronDrawables.add(chevron == null ? null : new TargetDrawable(chevron));
236                }
237            }
238        }
239
240        // Read array of target drawables
241        if (a.getValue(R.styleable.MultiWaveView_targetDrawables, outValue)) {
242            internalSetTargetResources(outValue.resourceId);
243        }
244        if (mTargetDrawables == null || mTargetDrawables.size() == 0) {
245            throw new IllegalStateException("Must specify at least one target drawable");
246        }
247
248        // Read array of target descriptions
249        if (a.getValue(R.styleable.MultiWaveView_targetDescriptions, outValue)) {
250            final int resourceId = outValue.resourceId;
251            if (resourceId == 0) {
252                throw new IllegalStateException("Must specify target descriptions");
253            }
254            setTargetDescriptionsResourceId(resourceId);
255        }
256
257        // Read array of direction descriptions
258        if (a.getValue(R.styleable.MultiWaveView_directionDescriptions, outValue)) {
259            final int resourceId = outValue.resourceId;
260            if (resourceId == 0) {
261                throw new IllegalStateException("Must specify direction descriptions");
262            }
263            setDirectionDescriptionsResourceId(resourceId);
264        }
265
266        a.recycle();
267
268        // Use gravity attribute from LinearLayout
269        a = context.obtainStyledAttributes(attrs, android.R.styleable.LinearLayout);
270        mGravity = a.getInt(android.R.styleable.LinearLayout_gravity, Gravity.TOP);
271        a.recycle();
272
273        setVibrateEnabled(mVibrationDuration > 0);
274        assignDefaultsIfNeeded();
275    }
276
277    private void dump() {
278        Log.v(TAG, "Outer Radius = " + mOuterRadius);
279        Log.v(TAG, "SnapMargin = " + mSnapMargin);
280        Log.v(TAG, "FeedbackCount = " + mFeedbackCount);
281        Log.v(TAG, "VibrationDuration = " + mVibrationDuration);
282        Log.v(TAG, "TapRadius = " + mTapRadius);
283        Log.v(TAG, "WaveCenterX = " + mWaveCenterX);
284        Log.v(TAG, "WaveCenterY = " + mWaveCenterY);
285    }
286
287    public void suspendAnimations() {
288        mChevronAnimations.setSuspended(true);
289        mTargetAnimations.setSuspended(true);
290        mHandleAnimations.setSuspended(true);
291    }
292
293    public void resumeAnimations() {
294        mChevronAnimations.setSuspended(false);
295        mTargetAnimations.setSuspended(false);
296        mHandleAnimations.setSuspended(false);
297        mChevronAnimations.start();
298        mTargetAnimations.start();
299        mHandleAnimations.start();
300    }
301
302    @Override
303    protected int getSuggestedMinimumWidth() {
304        // View should be large enough to contain the background + handle and
305        // target drawable on either edge.
306        return (int) (Math.max(mOuterRing.getWidth(), 2 * mOuterRadius) + mMaxTargetWidth);
307    }
308
309    @Override
310    protected int getSuggestedMinimumHeight() {
311        // View should be large enough to contain the unlock ring + target and
312        // target drawable on either edge
313        return (int) (Math.max(mOuterRing.getHeight(), 2 * mOuterRadius) + mMaxTargetHeight);
314    }
315
316    private int resolveMeasured(int measureSpec, int desired)
317    {
318        int result = 0;
319        int specSize = MeasureSpec.getSize(measureSpec);
320        switch (MeasureSpec.getMode(measureSpec)) {
321            case MeasureSpec.UNSPECIFIED:
322                result = desired;
323                break;
324            case MeasureSpec.AT_MOST:
325                result = Math.min(specSize, desired);
326                break;
327            case MeasureSpec.EXACTLY:
328            default:
329                result = specSize;
330        }
331        return result;
332    }
333
334    @Override
335    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
336        final int minimumWidth = getSuggestedMinimumWidth();
337        final int minimumHeight = getSuggestedMinimumHeight();
338        int computedWidth = resolveMeasured(widthMeasureSpec, minimumWidth);
339        int computedHeight = resolveMeasured(heightMeasureSpec, minimumHeight);
340        computeInsets((computedWidth - minimumWidth), (computedHeight - minimumHeight));
341        setMeasuredDimension(computedWidth, computedHeight);
342    }
343
344    private void switchToState(int state, float x, float y) {
345        switch (state) {
346            case STATE_IDLE:
347                deactivateTargets();
348                hideTargets(true, false);
349                startBackgroundAnimation(0, 0.0f);
350                mHandleDrawable.setState(TargetDrawable.STATE_INACTIVE);
351                break;
352
353            case STATE_START:
354                deactivateHandle(0, 0, 1.0f, null);
355                startBackgroundAnimation(0, 0.0f);
356                break;
357
358            case STATE_FIRST_TOUCH:
359                deactivateTargets();
360                showTargets(true);
361                mHandleDrawable.setState(TargetDrawable.STATE_ACTIVE);
362                startBackgroundAnimation(INITIAL_SHOW_HANDLE_DURATION, 1.0f);
363                setGrabbedState(OnTriggerListener.CENTER_HANDLE);
364                if (AccessibilityManager.getInstance(mContext).isEnabled()) {
365                    announceTargets();
366                }
367                break;
368
369            case STATE_TRACKING:
370                break;
371
372            case STATE_SNAP:
373                break;
374
375            case STATE_FINISH:
376                doFinish();
377                break;
378        }
379    }
380
381    private void activateHandle(int duration, int delay, float finalAlpha,
382            AnimatorListener finishListener) {
383        mHandleAnimations.cancel();
384        mHandleAnimations.add(Tweener.to(mHandleDrawable, duration,
385                "ease", Ease.Cubic.easeIn,
386                "delay", delay,
387                "alpha", finalAlpha,
388                "onUpdate", mUpdateListener,
389                "onComplete", finishListener));
390        mHandleAnimations.start();
391    }
392
393    private void deactivateHandle(int duration, int delay, float finalAlpha,
394            AnimatorListener finishListener) {
395        mHandleAnimations.cancel();
396        mHandleAnimations.add(Tweener.to(mHandleDrawable, duration,
397            "ease", Ease.Quart.easeOut,
398            "delay", delay,
399            "alpha", finalAlpha,
400            "x", 0,
401            "y", 0,
402            "onUpdate", mUpdateListener,
403            "onComplete", finishListener));
404        mHandleAnimations.start();
405    }
406
407    /**
408     * Animation used to attract user's attention to the target button.
409     * Assumes mChevronDrawables is an a list with an even number of chevrons filled with
410     * mFeedbackCount items in the order: left, right, top, bottom.
411     */
412    private void startChevronAnimation() {
413        final float chevronStartDistance = mHandleDrawable.getWidth() * 0.8f;
414        final float chevronStopDistance = mOuterRadius * 0.9f / 2.0f;
415        final float startScale = 0.5f;
416        final float endScale = 2.0f;
417        final int directionCount = mFeedbackCount > 0 ? mChevronDrawables.size()/mFeedbackCount : 0;
418
419        mChevronAnimations.stop();
420
421        // Add an animation for all chevron drawables.  There are mFeedbackCount drawables
422        // in each direction and directionCount directions.
423        for (int direction = 0; direction < directionCount; direction++) {
424            double angle = 2.0 * Math.PI * direction / directionCount;
425            final float sx = (float) Math.cos(angle);
426            final float sy = 0.0f - (float) Math.sin(angle);
427            final float[] xrange = new float[]
428                 {sx * chevronStartDistance, sx * chevronStopDistance};
429            final float[] yrange = new float[]
430                 {sy * chevronStartDistance, sy * chevronStopDistance};
431            for (int count = 0; count < mFeedbackCount; count++) {
432                int delay = count * CHEVRON_INCREMENTAL_DELAY;
433                final TargetDrawable icon = mChevronDrawables.get(direction*mFeedbackCount + count);
434                if (icon == null) {
435                    continue;
436                }
437                mChevronAnimations.add(Tweener.to(icon, CHEVRON_ANIMATION_DURATION,
438                        "ease", mChevronAnimationInterpolator,
439                        "delay", delay,
440                        "x", xrange,
441                        "y", yrange,
442                        "alpha", new float[] {1.0f, 0.0f},
443                        "scaleX", new float[] {startScale, endScale},
444                        "scaleY", new float[] {startScale, endScale},
445                        "onUpdate", mUpdateListener));
446            }
447        }
448        mChevronAnimations.start();
449    }
450
451    private void deactivateTargets() {
452        final int count = mTargetDrawables.size();
453        for (int i = 0; i < count; i++) {
454            TargetDrawable target = mTargetDrawables.get(i);
455            target.setState(TargetDrawable.STATE_INACTIVE);
456        }
457        mActiveTarget = -1;
458    }
459
460    void invalidateGlobalRegion(TargetDrawable drawable) {
461        int width = drawable.getWidth();
462        int height = drawable.getHeight();
463        RectF childBounds = new RectF(0, 0, width, height);
464        childBounds.offset(drawable.getX() - width/2, drawable.getY() - height/2);
465        View view = this;
466        while (view.getParent() != null && view.getParent() instanceof View) {
467            view = (View) view.getParent();
468            view.getMatrix().mapRect(childBounds);
469            view.invalidate((int) Math.floor(childBounds.left),
470                    (int) Math.floor(childBounds.top),
471                    (int) Math.ceil(childBounds.right),
472                    (int) Math.ceil(childBounds.bottom));
473        }
474    }
475
476    /**
477     * Dispatches a trigger event to listener. Ignored if a listener is not set.
478     * @param whichTarget the target that was triggered.
479     */
480    private void dispatchTriggerEvent(int whichTarget) {
481        vibrate();
482        if (mOnTriggerListener != null) {
483            mOnTriggerListener.onTrigger(this, whichTarget);
484        }
485    }
486
487    private void dispatchOnFinishFinalAnimation() {
488        if (mOnTriggerListener != null) {
489            mOnTriggerListener.onFinishFinalAnimation();
490        }
491    }
492
493    private void doFinish() {
494        final int activeTarget = mActiveTarget;
495        final boolean targetHit =  activeTarget != -1;
496
497        if (targetHit) {
498            if (DEBUG) Log.v(TAG, "Finish with target hit = " + targetHit);
499
500            highlightSelected(activeTarget);
501
502            // Inform listener of any active targets.  Typically only one will be active.
503            deactivateHandle(RETURN_TO_HOME_DURATION, RETURN_TO_HOME_DELAY, 0.0f, mResetListener);
504            dispatchTriggerEvent(activeTarget);
505            if (!mAlwaysTrackFinger) {
506                // Force ring and targets to finish animation to final expanded state
507                mTargetAnimations.stop();
508            }
509        } else {
510            // Animate handle back to the center based on current state.
511            deactivateHandle(HIDE_ANIMATION_DURATION, HIDE_ANIMATION_DELAY, 1.0f,
512                    mResetListenerWithPing);
513            hideTargets(true, false);
514        }
515
516        setGrabbedState(OnTriggerListener.NO_HANDLE);
517    }
518
519    private void highlightSelected(int activeTarget) {
520        // Highlight the given target and fade others
521        mTargetDrawables.get(activeTarget).setState(TargetDrawable.STATE_ACTIVE);
522        hideUnselected(activeTarget);
523    }
524
525    private void hideUnselected(int active) {
526        for (int i = 0; i < mTargetDrawables.size(); i++) {
527            if (i != active) {
528                mTargetDrawables.get(i).setAlpha(0.0f);
529            }
530        }
531    }
532
533    private void hideTargets(boolean animate, boolean expanded) {
534        mTargetAnimations.cancel();
535        // Note: these animations should complete at the same time so that we can swap out
536        // the target assets asynchronously from the setTargetResources() call.
537        mAnimatingTargets = animate;
538        final int duration = animate ? HIDE_ANIMATION_DURATION : 0;
539        final int delay = animate ? HIDE_ANIMATION_DELAY : 0;
540
541        final float targetScale = expanded ? TARGET_SCALE_EXPANDED : TARGET_SCALE_COLLAPSED;
542        final int length = mTargetDrawables.size();
543        for (int i = 0; i < length; i++) {
544            TargetDrawable target = mTargetDrawables.get(i);
545            target.setState(TargetDrawable.STATE_INACTIVE);
546            mTargetAnimations.add(Tweener.to(target, duration,
547                    "ease", Ease.Cubic.easeOut,
548                    "alpha", 0.0f,
549                    "scaleX", targetScale,
550                    "scaleY", targetScale,
551                    "delay", delay,
552                    "onUpdate", mUpdateListener));
553        }
554
555        final float ringScaleTarget = expanded ? RING_SCALE_EXPANDED : RING_SCALE_COLLAPSED;
556        mTargetAnimations.add(Tweener.to(mOuterRing, duration,
557                "ease", Ease.Cubic.easeOut,
558                "alpha", 0.0f,
559                "scaleX", ringScaleTarget,
560                "scaleY", ringScaleTarget,
561                "delay", delay,
562                "onUpdate", mUpdateListener,
563                "onComplete", mTargetUpdateListener));
564
565        mTargetAnimations.start();
566    }
567
568    private void showTargets(boolean animate) {
569        mTargetAnimations.stop();
570        mAnimatingTargets = animate;
571        final int delay = animate ? SHOW_ANIMATION_DELAY : 0;
572        final int duration = animate ? SHOW_ANIMATION_DURATION : 0;
573        final int length = mTargetDrawables.size();
574        for (int i = 0; i < length; i++) {
575            TargetDrawable target = mTargetDrawables.get(i);
576            target.setState(TargetDrawable.STATE_INACTIVE);
577            mTargetAnimations.add(Tweener.to(target, duration,
578                    "ease", Ease.Cubic.easeOut,
579                    "alpha", 1.0f,
580                    "scaleX", 1.0f,
581                    "scaleY", 1.0f,
582                    "delay", delay,
583                    "onUpdate", mUpdateListener));
584        }
585        mTargetAnimations.add(Tweener.to(mOuterRing, duration,
586                "ease", Ease.Cubic.easeOut,
587                "alpha", 1.0f,
588                "scaleX", 1.0f,
589                "scaleY", 1.0f,
590                "delay", delay,
591                "onUpdate", mUpdateListener,
592                "onComplete", mTargetUpdateListener));
593
594        mTargetAnimations.start();
595    }
596
597    private void vibrate() {
598        final boolean hapticEnabled = Settings.System.getIntForUser(
599                mContext.getContentResolver(), Settings.System.HAPTIC_FEEDBACK_ENABLED, 1,
600                UserHandle.USER_CURRENT) != 0;
601        if (mVibrator != null && hapticEnabled) {
602            mVibrator.vibrate(mVibrationDuration);
603        }
604    }
605
606    private ArrayList<TargetDrawable> loadDrawableArray(int resourceId) {
607        Resources res = getContext().getResources();
608        TypedArray array = res.obtainTypedArray(resourceId);
609        final int count = array.length();
610        ArrayList<TargetDrawable> drawables = new ArrayList<TargetDrawable>(count);
611        for (int i = 0; i < count; i++) {
612            TypedValue value = array.peekValue(i);
613            TargetDrawable target = new TargetDrawable(res, value != null ? value.resourceId : 0);
614            drawables.add(target);
615        }
616        array.recycle();
617        return drawables;
618    }
619
620    private void internalSetTargetResources(int resourceId) {
621        mTargetDrawables = loadDrawableArray(resourceId);
622        mTargetResourceId = resourceId;
623        final int count = mTargetDrawables.size();
624        int maxWidth = mHandleDrawable.getWidth();
625        int maxHeight = mHandleDrawable.getHeight();
626        for (int i = 0; i < count; i++) {
627            TargetDrawable target = mTargetDrawables.get(i);
628            maxWidth = Math.max(maxWidth, target.getWidth());
629            maxHeight = Math.max(maxHeight, target.getHeight());
630        }
631        if (mMaxTargetWidth != maxWidth || mMaxTargetHeight != maxHeight) {
632            mMaxTargetWidth = maxWidth;
633            mMaxTargetHeight = maxHeight;
634            requestLayout(); // required to resize layout and call updateTargetPositions()
635        } else {
636            updateTargetPositions(mWaveCenterX, mWaveCenterY);
637            updateChevronPositions(mWaveCenterX, mWaveCenterY);
638        }
639    }
640
641    /**
642     * Loads an array of drawables from the given resourceId.
643     *
644     * @param resourceId
645     */
646    public void setTargetResources(int resourceId) {
647        if (mAnimatingTargets) {
648            // postpone this change until we return to the initial state
649            mNewTargetResources = resourceId;
650        } else {
651            internalSetTargetResources(resourceId);
652        }
653    }
654
655    public int getTargetResourceId() {
656        return mTargetResourceId;
657    }
658
659    /**
660     * Sets the resource id specifying the target descriptions for accessibility.
661     *
662     * @param resourceId The resource id.
663     */
664    public void setTargetDescriptionsResourceId(int resourceId) {
665        mTargetDescriptionsResourceId = resourceId;
666        if (mTargetDescriptions != null) {
667            mTargetDescriptions.clear();
668        }
669    }
670
671    /**
672     * Gets the resource id specifying the target descriptions for accessibility.
673     *
674     * @return The resource id.
675     */
676    public int getTargetDescriptionsResourceId() {
677        return mTargetDescriptionsResourceId;
678    }
679
680    /**
681     * Sets the resource id specifying the target direction descriptions for accessibility.
682     *
683     * @param resourceId The resource id.
684     */
685    public void setDirectionDescriptionsResourceId(int resourceId) {
686        mDirectionDescriptionsResourceId = resourceId;
687        if (mDirectionDescriptions != null) {
688            mDirectionDescriptions.clear();
689        }
690    }
691
692    /**
693     * Gets the resource id specifying the target direction descriptions.
694     *
695     * @return The resource id.
696     */
697    public int getDirectionDescriptionsResourceId() {
698        return mDirectionDescriptionsResourceId;
699    }
700
701    /**
702     * Enable or disable vibrate on touch.
703     *
704     * @param enabled
705     */
706    public void setVibrateEnabled(boolean enabled) {
707        if (enabled && mVibrator == null) {
708            mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
709        } else {
710            mVibrator = null;
711        }
712    }
713
714    /**
715     * Starts chevron animation. Example use case: show chevron animation whenever the phone rings
716     * or the user touches the screen.
717     *
718     */
719    public void ping() {
720        startChevronAnimation();
721    }
722
723    /**
724     * Resets the widget to default state and cancels all animation. If animate is 'true', will
725     * animate objects into place. Otherwise, objects will snap back to place.
726     *
727     * @param animate
728     */
729    public void reset(boolean animate) {
730        mChevronAnimations.stop();
731        mHandleAnimations.stop();
732        mTargetAnimations.stop();
733        startBackgroundAnimation(0, 0.0f);
734        hideChevrons();
735        hideTargets(animate, false);
736        deactivateHandle(0, 0, 1.0f, null);
737        Tweener.reset();
738    }
739
740    private void startBackgroundAnimation(int duration, float alpha) {
741        Drawable background = getBackground();
742        if (mAlwaysTrackFinger && background != null) {
743            if (mBackgroundAnimator != null) {
744                mBackgroundAnimator.animator.end();
745            }
746            mBackgroundAnimator = Tweener.to(background, duration,
747                    "ease", Ease.Cubic.easeIn,
748                    "alpha", new int[] {0, (int)(255.0f * alpha)},
749                    "delay", SHOW_ANIMATION_DELAY);
750            mBackgroundAnimator.animator.start();
751        }
752    }
753
754    @Override
755    public boolean onTouchEvent(MotionEvent event) {
756        final int action = event.getAction();
757        boolean handled = false;
758        switch (action) {
759            case MotionEvent.ACTION_DOWN:
760                if (DEBUG) Log.v(TAG, "*** DOWN ***");
761                handleDown(event);
762                handled = true;
763                break;
764
765            case MotionEvent.ACTION_MOVE:
766                if (DEBUG) Log.v(TAG, "*** MOVE ***");
767                handleMove(event);
768                handled = true;
769                break;
770
771            case MotionEvent.ACTION_UP:
772                if (DEBUG) Log.v(TAG, "*** UP ***");
773                handleMove(event);
774                handleUp(event);
775                handled = true;
776                break;
777
778            case MotionEvent.ACTION_CANCEL:
779                if (DEBUG) Log.v(TAG, "*** CANCEL ***");
780                handleMove(event);
781                handleCancel(event);
782                handled = true;
783                break;
784        }
785        invalidate();
786        return handled ? true : super.onTouchEvent(event);
787    }
788
789    private void moveHandleTo(float x, float y, boolean animate) {
790        mHandleDrawable.setX(x);
791        mHandleDrawable.setY(y);
792    }
793
794    private void handleDown(MotionEvent event) {
795        float eventX = event.getX();
796        float eventY = event.getY();
797        switchToState(STATE_START, eventX, eventY);
798        if (!trySwitchToFirstTouchState(eventX, eventY)) {
799            mDragging = false;
800            ping();
801        }
802    }
803
804    private void handleUp(MotionEvent event) {
805        if (DEBUG && mDragging) Log.v(TAG, "** Handle RELEASE");
806        switchToState(STATE_FINISH, event.getX(), event.getY());
807    }
808
809    private void handleCancel(MotionEvent event) {
810        if (DEBUG && mDragging) Log.v(TAG, "** Handle CANCEL");
811
812        // We should drop the active target here but it interferes with
813        // moving off the screen in the direction of the navigation bar. At some point we may
814        // want to revisit how we handle this. For now we'll allow a canceled event to
815        // activate the current target.
816
817        // mActiveTarget = -1; // Drop the active target if canceled.
818
819        switchToState(STATE_FINISH, event.getX(), event.getY());
820    }
821
822    private void handleMove(MotionEvent event) {
823        int activeTarget = -1;
824        final int historySize = event.getHistorySize();
825        ArrayList<TargetDrawable> targets = mTargetDrawables;
826        int ntargets = targets.size();
827        float x = 0.0f;
828        float y = 0.0f;
829        for (int k = 0; k < historySize + 1; k++) {
830            float eventX = k < historySize ? event.getHistoricalX(k) : event.getX();
831            float eventY = k < historySize ? event.getHistoricalY(k) : event.getY();
832            // tx and ty are relative to wave center
833            float tx = eventX - mWaveCenterX;
834            float ty = eventY - mWaveCenterY;
835            float touchRadius = (float) Math.sqrt(dist2(tx, ty));
836            final float scale = touchRadius > mOuterRadius ? mOuterRadius / touchRadius : 1.0f;
837            float limitX = tx * scale;
838            float limitY = ty * scale;
839            double angleRad = Math.atan2(-ty, tx);
840
841            if (!mDragging) {
842                trySwitchToFirstTouchState(eventX, eventY);
843            }
844
845            if (mDragging) {
846                // For multiple targets, snap to the one that matches
847                final float snapRadius = mOuterRadius - mSnapMargin;
848                final float snapDistance2 = snapRadius * snapRadius;
849                // Find first target in range
850                for (int i = 0; i < ntargets; i++) {
851                    TargetDrawable target = targets.get(i);
852
853                    double targetMinRad = (i - 0.5) * 2 * Math.PI / ntargets;
854                    double targetMaxRad = (i + 0.5) * 2 * Math.PI / ntargets;
855                    if (target.isEnabled()) {
856                        boolean angleMatches =
857                            (angleRad > targetMinRad && angleRad <= targetMaxRad) ||
858                            (angleRad + 2 * Math.PI > targetMinRad &&
859                             angleRad + 2 * Math.PI <= targetMaxRad);
860                        if (angleMatches && (dist2(tx, ty) > snapDistance2)) {
861                            activeTarget = i;
862                        }
863                    }
864                }
865            }
866            x = limitX;
867            y = limitY;
868        }
869
870        if (!mDragging) {
871            return;
872        }
873
874        if (activeTarget != -1) {
875            switchToState(STATE_SNAP, x,y);
876            moveHandleTo(x, y, false);
877        } else {
878            switchToState(STATE_TRACKING, x, y);
879            moveHandleTo(x, y, false);
880        }
881
882        // Draw handle outside parent's bounds
883        invalidateGlobalRegion(mHandleDrawable);
884
885        if (mActiveTarget != activeTarget) {
886            // Defocus the old target
887            if (mActiveTarget != -1) {
888                TargetDrawable target = targets.get(mActiveTarget);
889                if (target.hasState(TargetDrawable.STATE_FOCUSED)) {
890                    target.setState(TargetDrawable.STATE_INACTIVE);
891                }
892            }
893            // Focus the new target
894            if (activeTarget != -1) {
895                TargetDrawable target = targets.get(activeTarget);
896                if (target.hasState(TargetDrawable.STATE_FOCUSED)) {
897                    target.setState(TargetDrawable.STATE_FOCUSED);
898                }
899                if (AccessibilityManager.getInstance(mContext).isEnabled()) {
900                    String targetContentDescription = getTargetDescription(activeTarget);
901                    announceText(targetContentDescription);
902                }
903                activateHandle(0, 0, 0.0f, null);
904            } else {
905                activateHandle(0, 0, 1.0f, null);
906            }
907        }
908        mActiveTarget = activeTarget;
909    }
910
911    @Override
912    public boolean onHoverEvent(MotionEvent event) {
913        if (AccessibilityManager.getInstance(mContext).isTouchExplorationEnabled()) {
914            final int action = event.getAction();
915            switch (action) {
916                case MotionEvent.ACTION_HOVER_ENTER:
917                    event.setAction(MotionEvent.ACTION_DOWN);
918                    break;
919                case MotionEvent.ACTION_HOVER_MOVE:
920                    event.setAction(MotionEvent.ACTION_MOVE);
921                    break;
922                case MotionEvent.ACTION_HOVER_EXIT:
923                    event.setAction(MotionEvent.ACTION_UP);
924                    break;
925            }
926            onTouchEvent(event);
927            event.setAction(action);
928        }
929        return super.onHoverEvent(event);
930    }
931
932    /**
933     * Sets the current grabbed state, and dispatches a grabbed state change
934     * event to our listener.
935     */
936    private void setGrabbedState(int newState) {
937        if (newState != mGrabbedState) {
938            if (newState != OnTriggerListener.NO_HANDLE) {
939                vibrate();
940            }
941            mGrabbedState = newState;
942            if (mOnTriggerListener != null) {
943                if (newState == OnTriggerListener.NO_HANDLE) {
944                    mOnTriggerListener.onReleased(this, OnTriggerListener.CENTER_HANDLE);
945                } else {
946                    mOnTriggerListener.onGrabbed(this, OnTriggerListener.CENTER_HANDLE);
947                }
948                mOnTriggerListener.onGrabbedStateChange(this, newState);
949            }
950        }
951    }
952
953    private boolean trySwitchToFirstTouchState(float x, float y) {
954        final float tx = x - mWaveCenterX;
955        final float ty = y - mWaveCenterY;
956        if (mAlwaysTrackFinger || dist2(tx,ty) <= getScaledTapRadiusSquared()) {
957            if (DEBUG) Log.v(TAG, "** Handle HIT");
958            switchToState(STATE_FIRST_TOUCH, x, y);
959            moveHandleTo(tx, ty, false);
960            mDragging = true;
961            return true;
962        }
963        return false;
964    }
965
966    private void assignDefaultsIfNeeded() {
967        if (mOuterRadius == 0.0f) {
968            mOuterRadius = Math.max(mOuterRing.getWidth(), mOuterRing.getHeight())/2.0f;
969        }
970        if (mSnapMargin == 0.0f) {
971            mSnapMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
972                    SNAP_MARGIN_DEFAULT, getContext().getResources().getDisplayMetrics());
973        }
974    }
975
976    private void computeInsets(int dx, int dy) {
977        final int layoutDirection = getLayoutDirection();
978        final int absoluteGravity = Gravity.getAbsoluteGravity(mGravity, layoutDirection);
979
980        switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
981            case Gravity.LEFT:
982                mHorizontalInset = 0;
983                break;
984            case Gravity.RIGHT:
985                mHorizontalInset = dx;
986                break;
987            case Gravity.CENTER_HORIZONTAL:
988            default:
989                mHorizontalInset = dx / 2;
990                break;
991        }
992        switch (absoluteGravity & Gravity.VERTICAL_GRAVITY_MASK) {
993            case Gravity.TOP:
994                mVerticalInset = 0;
995                break;
996            case Gravity.BOTTOM:
997                mVerticalInset = dy;
998                break;
999            case Gravity.CENTER_VERTICAL:
1000            default:
1001                mVerticalInset = dy / 2;
1002                break;
1003        }
1004    }
1005
1006    @Override
1007    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
1008        super.onLayout(changed, left, top, right, bottom);
1009        final int width = right - left;
1010        final int height = bottom - top;
1011
1012        // Target placement width/height. This puts the targets on the greater of the ring
1013        // width or the specified outer radius.
1014        final float placementWidth = Math.max(mOuterRing.getWidth(), 2 * mOuterRadius);
1015        final float placementHeight = Math.max(mOuterRing.getHeight(), 2 * mOuterRadius);
1016        float newWaveCenterX = mHorizontalInset
1017                + Math.max(width, mMaxTargetWidth + placementWidth) / 2;
1018        float newWaveCenterY = mVerticalInset
1019                + Math.max(height, + mMaxTargetHeight + placementHeight) / 2;
1020
1021        if (mInitialLayout) {
1022            hideChevrons();
1023            hideTargets(false, false);
1024            moveHandleTo(0, 0, false);
1025            mInitialLayout = false;
1026        }
1027
1028        mOuterRing.setPositionX(newWaveCenterX);
1029        mOuterRing.setPositionY(newWaveCenterY);
1030
1031        mHandleDrawable.setPositionX(newWaveCenterX);
1032        mHandleDrawable.setPositionY(newWaveCenterY);
1033
1034        updateTargetPositions(newWaveCenterX, newWaveCenterY);
1035        updateChevronPositions(newWaveCenterX, newWaveCenterY);
1036
1037        mWaveCenterX = newWaveCenterX;
1038        mWaveCenterY = newWaveCenterY;
1039
1040        if (DEBUG) dump();
1041    }
1042
1043    private void updateTargetPositions(float centerX, float centerY) {
1044        // Reposition the target drawables if the view changed.
1045        ArrayList<TargetDrawable> targets = mTargetDrawables;
1046        final int size = targets.size();
1047        final float alpha = (float) (-2.0f * Math.PI / size);
1048        for (int i = 0; i < size; i++) {
1049            final TargetDrawable targetIcon = targets.get(i);
1050            final float angle = alpha * i;
1051            targetIcon.setPositionX(centerX);
1052            targetIcon.setPositionY(centerY);
1053            targetIcon.setX(mOuterRadius * (float) Math.cos(angle));
1054            targetIcon.setY(mOuterRadius * (float) Math.sin(angle));
1055        }
1056    }
1057
1058    private void updateChevronPositions(float centerX, float centerY) {
1059        ArrayList<TargetDrawable> chevrons = mChevronDrawables;
1060        final int size = chevrons.size();
1061        for (int i = 0; i < size; i++) {
1062            TargetDrawable target = chevrons.get(i);
1063            if (target != null) {
1064                target.setPositionX(centerX);
1065                target.setPositionY(centerY);
1066            }
1067        }
1068    }
1069
1070    private void hideChevrons() {
1071        ArrayList<TargetDrawable> chevrons = mChevronDrawables;
1072        final int size = chevrons.size();
1073        for (int i = 0; i < size; i++) {
1074            TargetDrawable chevron = chevrons.get(i);
1075            if (chevron != null) {
1076                chevron.setAlpha(0.0f);
1077            }
1078        }
1079    }
1080
1081    @Override
1082    protected void onDraw(Canvas canvas) {
1083        mOuterRing.draw(canvas);
1084        final int ntargets = mTargetDrawables.size();
1085        for (int i = 0; i < ntargets; i++) {
1086            TargetDrawable target = mTargetDrawables.get(i);
1087            if (target != null) {
1088                target.draw(canvas);
1089            }
1090        }
1091        final int nchevrons = mChevronDrawables.size();
1092        for (int i = 0; i < nchevrons; i++) {
1093            TargetDrawable chevron = mChevronDrawables.get(i);
1094            if (chevron != null) {
1095                chevron.draw(canvas);
1096            }
1097        }
1098        mHandleDrawable.draw(canvas);
1099    }
1100
1101    public void setOnTriggerListener(OnTriggerListener listener) {
1102        mOnTriggerListener = listener;
1103    }
1104
1105    private float square(float d) {
1106        return d * d;
1107    }
1108
1109    private float dist2(float dx, float dy) {
1110        return dx*dx + dy*dy;
1111    }
1112
1113    private float getScaledTapRadiusSquared() {
1114        final float scaledTapRadius;
1115        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
1116            scaledTapRadius = TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED * mTapRadius;
1117        } else {
1118            scaledTapRadius = mTapRadius;
1119        }
1120        return square(scaledTapRadius);
1121    }
1122
1123    private void announceTargets() {
1124        StringBuilder utterance = new StringBuilder();
1125        final int targetCount = mTargetDrawables.size();
1126        for (int i = 0; i < targetCount; i++) {
1127            String targetDescription = getTargetDescription(i);
1128            String directionDescription = getDirectionDescription(i);
1129            if (!TextUtils.isEmpty(targetDescription)
1130                    && !TextUtils.isEmpty(directionDescription)) {
1131                String text = String.format(directionDescription, targetDescription);
1132                utterance.append(text);
1133            }
1134            if (utterance.length() > 0) {
1135                announceText(utterance.toString());
1136            }
1137        }
1138    }
1139
1140    private void announceText(String text) {
1141        setContentDescription(text);
1142        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
1143        setContentDescription(null);
1144    }
1145
1146    private String getTargetDescription(int index) {
1147        if (mTargetDescriptions == null || mTargetDescriptions.isEmpty()) {
1148            mTargetDescriptions = loadDescriptions(mTargetDescriptionsResourceId);
1149            if (mTargetDrawables.size() != mTargetDescriptions.size()) {
1150                Log.w(TAG, "The number of target drawables must be"
1151                        + " euqal to the number of target descriptions.");
1152                return null;
1153            }
1154        }
1155        return mTargetDescriptions.get(index);
1156    }
1157
1158    private String getDirectionDescription(int index) {
1159        if (mDirectionDescriptions == null || mDirectionDescriptions.isEmpty()) {
1160            mDirectionDescriptions = loadDescriptions(mDirectionDescriptionsResourceId);
1161            if (mTargetDrawables.size() != mDirectionDescriptions.size()) {
1162                Log.w(TAG, "The number of target drawables must be"
1163                        + " euqal to the number of direction descriptions.");
1164                return null;
1165            }
1166        }
1167        return mDirectionDescriptions.get(index);
1168    }
1169
1170    private ArrayList<String> loadDescriptions(int resourceId) {
1171        TypedArray array = getContext().getResources().obtainTypedArray(resourceId);
1172        final int count = array.length();
1173        ArrayList<String> targetContentDescriptions = new ArrayList<String>(count);
1174        for (int i = 0; i < count; i++) {
1175            String contentDescription = array.getString(i);
1176            targetContentDescriptions.add(contentDescription);
1177        }
1178        array.recycle();
1179        return targetContentDescriptions;
1180    }
1181
1182    public int getResourceIdForTarget(int index) {
1183        final TargetDrawable drawable = mTargetDrawables.get(index);
1184        return drawable == null ? 0 : drawable.getResourceId();
1185    }
1186
1187    public void setEnableTarget(int resourceId, boolean enabled) {
1188        for (int i = 0; i < mTargetDrawables.size(); i++) {
1189            final TargetDrawable target = mTargetDrawables.get(i);
1190            if (target.getResourceId() == resourceId) {
1191                target.setEnabled(enabled);
1192                break; // should never be more than one match
1193            }
1194        }
1195    }
1196
1197    /**
1198     * Gets the position of a target in the array that matches the given resource.
1199     * @param resourceId
1200     * @return the index or -1 if not found
1201     */
1202    public int getTargetPosition(int resourceId) {
1203        for (int i = 0; i < mTargetDrawables.size(); i++) {
1204            final TargetDrawable target = mTargetDrawables.get(i);
1205            if (target.getResourceId() == resourceId) {
1206                return i; // should never be more than one match
1207            }
1208        }
1209        return -1;
1210    }
1211
1212    private boolean replaceTargetDrawables(Resources res, int existingResourceId,
1213            int newResourceId) {
1214        if (existingResourceId == 0 || newResourceId == 0) {
1215            return false;
1216        }
1217
1218        boolean result = false;
1219        final ArrayList<TargetDrawable> drawables = mTargetDrawables;
1220        final int size = drawables.size();
1221        for (int i = 0; i < size; i++) {
1222            final TargetDrawable target = drawables.get(i);
1223            if (target != null && target.getResourceId() == existingResourceId) {
1224                target.setDrawable(res, newResourceId);
1225                result = true;
1226            }
1227        }
1228
1229        if (result) {
1230            requestLayout(); // in case any given drawable's size changes
1231        }
1232
1233        return result;
1234    }
1235
1236    /**
1237     * Searches the given package for a resource to use to replace the Drawable on the
1238     * target with the given resource id
1239     * @param component of the .apk that contains the resource
1240     * @param name of the metadata in the .apk
1241     * @param existingResId the resource id of the target to search for
1242     * @return true if found in the given package and replaced at least one target Drawables
1243     */
1244    public boolean replaceTargetDrawablesIfPresent(ComponentName component, String name,
1245                int existingResId) {
1246        if (existingResId == 0) return false;
1247
1248        try {
1249            PackageManager packageManager = mContext.getPackageManager();
1250            // Look for the search icon specified in the activity meta-data
1251            Bundle metaData = packageManager.getActivityInfo(
1252                    component, PackageManager.GET_META_DATA).metaData;
1253            if (metaData != null) {
1254                int iconResId = metaData.getInt(name);
1255                if (iconResId != 0) {
1256                    Resources res = packageManager.getResourcesForActivity(component);
1257                    return replaceTargetDrawables(res, existingResId, iconResId);
1258                }
1259            }
1260        } catch (NameNotFoundException e) {
1261            Log.w(TAG, "Failed to swap drawable; "
1262                    + component.flattenToShortString() + " not found", e);
1263        } catch (Resources.NotFoundException nfe) {
1264            Log.w(TAG, "Failed to swap drawable from "
1265                    + component.flattenToShortString(), nfe);
1266        }
1267        return false;
1268    }
1269}
1270