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 */
16package com.android.dreams.phototable;
17
18import android.content.Context;
19import android.content.res.Resources;
20import android.graphics.Bitmap;
21import android.graphics.BitmapFactory;
22import android.graphics.PointF;
23import android.graphics.PorterDuff;
24import android.graphics.Rect;
25import android.graphics.drawable.BitmapDrawable;
26import android.graphics.drawable.Drawable;
27import android.graphics.drawable.LayerDrawable;
28import android.os.AsyncTask;
29import android.service.dreams.DreamService;
30import android.util.AttributeSet;
31import android.util.Log;
32import android.view.KeyEvent;
33import android.view.LayoutInflater;
34import android.view.MotionEvent;
35import android.view.View;
36import android.view.ViewGroup;
37import android.view.ViewParent;
38import android.view.ViewPropertyAnimator;
39import android.view.animation.DecelerateInterpolator;
40import android.view.animation.Interpolator;
41import android.widget.FrameLayout;
42import android.widget.ImageView;
43
44import java.util.ArrayList;
45import java.util.Formatter;
46import java.util.HashSet;
47import java.util.LinkedList;
48import java.util.Random;
49import java.util.Set;
50
51/**
52 * A surface where photos sit.
53 */
54public class PhotoTable extends FrameLayout {
55    private static final String TAG = "PhotoTable";
56    private static final boolean DEBUG = false;
57
58    class Launcher implements Runnable {
59        @Override
60        public void run() {
61            PhotoTable.this.scheduleNext(mDropPeriod);
62            PhotoTable.this.launch();
63        }
64    }
65
66    class FocusReaper implements Runnable {
67        @Override
68        public void run() {
69            PhotoTable.this.clearFocus();
70        }
71    }
72
73    class SelectionReaper implements Runnable {
74        @Override
75        public void run() {
76            PhotoTable.this.clearSelection();
77        }
78    }
79
80    private static final int NEXT = 1;
81    private static final int PREV = 0;
82    private static Random sRNG = new Random();
83
84    private final Launcher mLauncher;
85    private final FocusReaper mFocusReaper;
86    private final SelectionReaper mSelectionReaper;
87    private final LinkedList<View> mOnTable;
88    private final int mDropPeriod;
89    private final int mFastDropPeriod;
90    private final int mNowDropDelay;
91    private final float mImageRatio;
92    private final float mTableRatio;
93    private final float mImageRotationLimit;
94    private final float mThrowRotation;
95    private final float mThrowSpeed;
96    private final boolean mTapToExit;
97    private final int mTableCapacity;
98    private final int mRedealCount;
99    private final int mInset;
100    private final PhotoSource mPhotoSource;
101    private final Resources mResources;
102    private final Interpolator mThrowInterpolator;
103    private final Interpolator mDropInterpolator;
104    private final DragGestureDetector mDragGestureDetector;
105    private final EdgeSwipeDetector mEdgeSwipeDetector;
106    private final KeyboardInterpreter mKeyboardInterpreter;
107    private final boolean mStoryModeEnabled;
108    private final boolean mBackgroudOptimization;
109    private final long mPickUpDuration;
110    private final int mMaxSelectionTime;
111    private final int mMaxFocusTime;
112    private DreamService mDream;
113    private PhotoLaunchTask mPhotoLaunchTask;
114    private LoadNaturalSiblingTask mLoadOnDeckTasks[];
115    private boolean mStarted;
116    private boolean mIsLandscape;
117    private int mLongSide;
118    private int mShortSide;
119    private int mWidth;
120    private int mHeight;
121    private View mSelection;
122    private View mOnDeck[];
123    private View mFocus;
124    private int mHighlightColor;
125    private ViewGroup mBackground;
126    private ViewGroup mStageLeft;
127    private View mScrim;
128    private final Set<View> mWaitingToJoinBackground;
129
130    public PhotoTable(Context context, AttributeSet as) {
131        super(context, as);
132        mResources = getResources();
133        mInset = mResources.getDimensionPixelSize(R.dimen.photo_inset);
134        mDropPeriod = mResources.getInteger(R.integer.table_drop_period);
135        mFastDropPeriod = mResources.getInteger(R.integer.fast_drop);
136        mNowDropDelay = mResources.getInteger(R.integer.now_drop);
137        mImageRatio = mResources.getInteger(R.integer.image_ratio) / 1000000f;
138        mTableRatio = mResources.getInteger(R.integer.table_ratio) / 1000000f;
139        mImageRotationLimit = (float) mResources.getInteger(R.integer.max_image_rotation);
140        mThrowSpeed = mResources.getDimension(R.dimen.image_throw_speed);
141        mPickUpDuration = mResources.getInteger(R.integer.photo_pickup_duration);
142        mThrowRotation = (float) mResources.getInteger(R.integer.image_throw_rotatioan);
143        mTableCapacity = mResources.getInteger(R.integer.table_capacity);
144        mRedealCount = mResources.getInteger(R.integer.redeal_count);
145        mTapToExit = mResources.getBoolean(R.bool.enable_tap_to_exit);
146        mStoryModeEnabled = mResources.getBoolean(R.bool.enable_story_mode);
147        mBackgroudOptimization = mResources.getBoolean(R.bool.enable_background_optimization);
148        mHighlightColor = mResources.getColor(R.color.highlight_color);
149        mMaxSelectionTime = mResources.getInteger(R.integer.max_selection_time);
150        mMaxFocusTime = mResources.getInteger(R.integer.max_focus_time);
151        mThrowInterpolator = new SoftLandingInterpolator(
152                mResources.getInteger(R.integer.soft_landing_time) / 1000000f,
153                mResources.getInteger(R.integer.soft_landing_distance) / 1000000f);
154        mDropInterpolator = new DecelerateInterpolator(
155                (float) mResources.getInteger(R.integer.drop_deceleration_exponent));
156        mOnTable = new LinkedList<View>();
157        mPhotoSource = new PhotoSourcePlexor(getContext(),
158                getContext().getSharedPreferences(PhotoTableDreamSettings.PREFS_NAME, 0));
159        mWaitingToJoinBackground = new HashSet<View>();
160        mLauncher = new Launcher();
161        mFocusReaper = new FocusReaper();
162        mSelectionReaper = new SelectionReaper();
163        mDragGestureDetector = new DragGestureDetector(context, this);
164        mEdgeSwipeDetector = new EdgeSwipeDetector(context, this);
165        mKeyboardInterpreter = new KeyboardInterpreter(this);
166        mLoadOnDeckTasks = new LoadNaturalSiblingTask[2];
167        mOnDeck = new View[2];
168        mStarted = false;
169    }
170
171    @Override
172    public void onFinishInflate() {
173        mBackground = (ViewGroup) findViewById(R.id.background);
174        mStageLeft = (ViewGroup) findViewById(R.id.stageleft);
175        mScrim = findViewById(R.id.scrim);
176    }
177
178    public void setDream(DreamService dream) {
179        mDream = dream;
180    }
181
182    public boolean hasSelection() {
183        return mSelection != null;
184    }
185
186    public View getSelection() {
187        return mSelection;
188    }
189
190    public void clearSelection() {
191        if (hasSelection()) {
192            dropOnTable(mSelection);
193            mPhotoSource.donePaging(getBitmap(mSelection));
194            if (mStoryModeEnabled) {
195                fadeInBackground(mSelection);
196            }
197            mSelection = null;
198        }
199        for (int slot = 0; slot < mOnDeck.length; slot++) {
200            if (mOnDeck[slot] != null) {
201                fadeAway(mOnDeck[slot], false);
202                mOnDeck[slot] = null;
203            }
204            if (mLoadOnDeckTasks[slot] != null &&
205                    mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) {
206                mLoadOnDeckTasks[slot].cancel(true);
207                mLoadOnDeckTasks[slot] = null;
208            }
209        }
210    }
211
212    public void setSelection(View selected) {
213        if (selected != null) {
214            clearSelection();
215            mSelection = selected;
216            promoteSelection();
217            if (mStoryModeEnabled) {
218                fadeOutBackground(mSelection);
219            }
220        }
221    }
222
223    public void selectNext() {
224        if (mStoryModeEnabled) {
225            log("selectNext");
226            if (hasSelection() && mOnDeck[NEXT] != null) {
227                placeOnDeck(mSelection, PREV);
228                mSelection = mOnDeck[NEXT];
229                mOnDeck[NEXT] = null;
230                promoteSelection();
231            }
232        } else {
233            clearSelection();
234        }
235    }
236
237    public void selectPrevious() {
238        if (mStoryModeEnabled) {
239            log("selectPrevious");
240            if (hasSelection() && mOnDeck[PREV] != null) {
241                placeOnDeck(mSelection, NEXT);
242                mSelection = mOnDeck[PREV];
243                mOnDeck[PREV] = null;
244                promoteSelection();
245            }
246        } else {
247            clearSelection();
248        }
249    }
250
251    private void promoteSelection() {
252        if (hasSelection()) {
253            scheduleSelectionReaper(mMaxSelectionTime);
254            mSelection.animate().cancel();
255            mSelection.setAlpha(1f);
256            moveToTopOfPile(mSelection);
257            pickUp(mSelection);
258            if (mStoryModeEnabled) {
259                for (int slot = 0; slot < mOnDeck.length; slot++) {
260                    if (mLoadOnDeckTasks[slot] != null &&
261                            mLoadOnDeckTasks[slot].getStatus() != AsyncTask.Status.FINISHED) {
262                        mLoadOnDeckTasks[slot].cancel(true);
263                    }
264                    if (mOnDeck[slot] == null) {
265                        mLoadOnDeckTasks[slot] = new LoadNaturalSiblingTask(slot);
266                        mLoadOnDeckTasks[slot].execute(mSelection);
267                    }
268                }
269            }
270        }
271    }
272
273    public boolean hasFocus() {
274        return mFocus != null;
275    }
276
277    public View getFocus() {
278        return mFocus;
279    }
280
281    public void clearFocus() {
282        if (hasFocus()) {
283            setHighlight(getFocus(), false);
284        }
285        mFocus = null;
286    }
287
288    public void setDefaultFocus() {
289        if (mOnTable.size() > 0) {
290            setFocus(mOnTable.getLast());
291        }
292    }
293
294    public void setFocus(View focus) {
295        assert(focus != null);
296        clearFocus();
297        mFocus = focus;
298        moveToTopOfPile(focus);
299        setHighlight(focus, true);
300        scheduleFocusReaper(mMaxFocusTime);
301    }
302
303    static float lerp(float a, float b, float f) {
304        return (b-a)*f + a;
305    }
306
307    static float randfrange(float a, float b) {
308        return lerp(a, b, sRNG.nextFloat());
309    }
310
311    static PointF randFromCurve(float t, PointF[] v) {
312        PointF p = new PointF();
313        if (v.length == 4 && t >= 0f && t <= 1f) {
314            float a = (float) Math.pow(1f-t, 3f);
315            float b = (float) Math.pow(1f-t, 2f) * t;
316            float c = (1f-t) * (float) Math.pow(t, 2f);
317            float d = (float) Math.pow(t, 3f);
318
319            p.x = a * v[0].x + 3 * b * v[1].x + 3 * c * v[2].x + d * v[3].x;
320            p.y = a * v[0].y + 3 * b * v[1].y + 3 * c * v[2].y + d * v[3].y;
321        }
322        return p;
323    }
324
325    private static PointF randMultiDrop(int n, float i, float j, int width, int height) {
326        log("randMultiDrop (%d, %f, %f, %d, %d)", n, i, j, width, height);
327        final float[] cx = {0.3f, 0.3f, 0.5f, 0.7f, 0.7f};
328        final float[] cy = {0.3f, 0.7f, 0.5f, 0.3f, 0.7f};
329        n = Math.abs(n);
330        float x = cx[n % cx.length];
331        float y = cy[n % cx.length];
332        PointF p = new PointF();
333        p.x = x * width + 0.05f * width * i;
334        p.y = y * height + 0.05f * height * j;
335        log("randInCenter returning %f, %f", p.x, p.y);
336        return p;
337    }
338
339    private double cross(double[] a, double[] b) {
340        return a[0] * b[1] - a[1] * b[0];
341    }
342
343    private double norm(double[] a) {
344        return Math.hypot(a[0], a[1]);
345    }
346
347    private double[] getCenter(View photo) {
348        float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue();
349        float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue();
350        double[] center = { photo.getX() + width / 2f,
351                            - (photo.getY() + height / 2f) };
352        return center;
353    }
354
355    public View moveFocus(View focus, float direction) {
356        return moveFocus(focus, direction, 90f);
357    }
358
359    public View moveFocus(View focus, float direction, float angle) {
360        if (focus == null) {
361            if (mOnTable.size() > 0) {
362                setFocus(mOnTable.getLast());
363            }
364        } else {
365            final double alpha = Math.toRadians(direction);
366            final double beta = Math.toRadians(Math.min(angle, 180f) / 2f);
367            final double[] left = { Math.sin(alpha - beta),
368                                    Math.cos(alpha - beta) };
369            final double[] right = { Math.sin(alpha + beta),
370                                     Math.cos(alpha + beta) };
371            final double[] a = getCenter(focus);
372            View bestFocus = null;
373            double bestDistance = Double.MAX_VALUE;
374            for (View candidate: mOnTable) {
375                if (candidate != focus) {
376                    final double[] b = getCenter(candidate);
377                    final double[] delta = { b[0] - a[0],
378                                             b[1] - a[1] };
379                    if (cross(delta, left) > 0.0 && cross(delta, right) < 0.0) {
380                        final double distance = norm(delta);
381                        if (bestDistance > distance) {
382                            bestDistance = distance;
383                            bestFocus = candidate;
384                        }
385                    }
386                }
387            }
388            if (bestFocus == null) {
389                if (angle < 180f) {
390                    return moveFocus(focus, direction, 180f);
391                }
392            } else {
393                setFocus(bestFocus);
394            }
395        }
396        return getFocus();
397    }
398
399    @Override
400    public boolean onKeyDown(int keyCode, KeyEvent event) {
401        return mKeyboardInterpreter.onKeyDown(keyCode, event);
402    }
403
404    @Override
405    public boolean onGenericMotionEvent(MotionEvent event) {
406        return mEdgeSwipeDetector.onTouchEvent(event) || mDragGestureDetector.onTouchEvent(event);
407    }
408
409    @Override
410    public boolean onTouchEvent(MotionEvent event) {
411        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
412            if (hasSelection()) {
413                clearSelection();
414            } else  {
415                if (mTapToExit && mDream != null) {
416                    mDream.finish();
417                }
418            }
419            return true;
420        }
421        return false;
422    }
423
424    @Override
425    public void onLayout(boolean changed, int left, int top, int right, int bottom) {
426        super.onLayout(changed, left, top, right, bottom);
427        log("onLayout (%d, %d, %d, %d)", left, top, right, bottom);
428
429        mHeight = bottom - top;
430        mWidth = right - left;
431
432        mLongSide = (int) (mImageRatio * Math.max(mWidth, mHeight));
433        mShortSide = (int) (mImageRatio * Math.min(mWidth, mHeight));
434
435        boolean isLandscape = mWidth > mHeight;
436        if (mIsLandscape != isLandscape) {
437            for (View photo: mOnTable) {
438                if (photo != getSelection()) {
439                    dropOnTable(photo);
440                }
441            }
442            if (hasSelection()) {
443                pickUp(getSelection());
444                for (int slot = 0; slot < mOnDeck.length; slot++) {
445                    if (mOnDeck[slot] != null) {
446                        placeOnDeck(mOnDeck[slot], slot);
447                    }
448                }
449            }
450            mIsLandscape = isLandscape;
451        }
452        start();
453    }
454
455    @Override
456    public boolean isOpaque() {
457        return true;
458    }
459
460    /** Put a nice border on the bitmap. */
461    private static View applyFrame(final PhotoTable table, final BitmapFactory.Options options,
462            Bitmap decodedPhoto) {
463        LayoutInflater inflater = (LayoutInflater) table.getContext()
464            .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
465        View photo = inflater.inflate(R.layout.photo, null);
466        ImageView image = (ImageView) photo;
467        Drawable[] layers = new Drawable[2];
468        int photoWidth = options.outWidth;
469        int photoHeight = options.outHeight;
470        if (decodedPhoto == null || options.outWidth <= 0 || options.outHeight <= 0) {
471            photo = null;
472        } else {
473            decodedPhoto.setHasMipMap(true);
474            layers[0] = new BitmapDrawable(table.mResources, decodedPhoto);
475            layers[1] = table.mResources.getDrawable(R.drawable.frame);
476            LayerDrawable layerList = new LayerDrawable(layers);
477            layerList.setLayerInset(0, table.mInset, table.mInset,
478                                    table.mInset, table.mInset);
479            image.setImageDrawable(layerList);
480
481            photo.setTag(R.id.photo_width, Integer.valueOf(photoWidth));
482            photo.setTag(R.id.photo_height, Integer.valueOf(photoHeight));
483
484            photo.setOnTouchListener(new PhotoTouchListener(table.getContext(),
485                                                            table));
486        }
487        return photo;
488    }
489
490    private class LoadNaturalSiblingTask extends AsyncTask<View, Void, View> {
491        private final BitmapFactory.Options mOptions;
492        private final int mSlot;
493        private View mParent;
494
495        public LoadNaturalSiblingTask (int slot) {
496            mOptions = new BitmapFactory.Options();
497            mOptions.inTempStorage = new byte[32768];
498            mSlot = slot;
499        }
500
501        @Override
502        public View doInBackground(View... views) {
503            log("load natural %s", (mSlot == NEXT ? "next" : "previous"));
504            final PhotoTable table = PhotoTable.this;
505            mParent = views[0];
506            final Bitmap current = getBitmap(mParent);
507            Bitmap decodedPhoto;
508            if (mSlot == NEXT) {
509                decodedPhoto = table.mPhotoSource.naturalNext(current,
510                    mOptions, table.mLongSide, table.mShortSide);
511            } else {
512                decodedPhoto = table.mPhotoSource.naturalPrevious(current,
513                    mOptions, table.mLongSide, table.mShortSide);
514            }
515            return applyFrame(PhotoTable.this, mOptions, decodedPhoto);
516        }
517
518        @Override
519        public void onPostExecute(View photo) {
520            if (photo != null) {
521                if (hasSelection() && getSelection() == mParent) {
522                    log("natural %s being rendered", (mSlot == NEXT ? "next" : "previous"));
523                    PhotoTable.this.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
524                            LayoutParams.WRAP_CONTENT));
525                    PhotoTable.this.mOnDeck[mSlot] = photo;
526                    float width = (float) ((Integer) photo.getTag(R.id.photo_width)).intValue();
527                    float height = (float) ((Integer) photo.getTag(R.id.photo_height)).intValue();
528                    photo.setX(mSlot == PREV ? -2 * width : mWidth + 2 * width);
529                    photo.setY((mHeight - height) / 2);
530                    photo.addOnLayoutChangeListener(new OnLayoutChangeListener() {
531                        @Override
532                        public void onLayoutChange(View v, int left, int top, int right, int bottom,
533                                int oldLeft, int oldTop, int oldRight, int oldBottom) {
534                            PhotoTable.this.placeOnDeck(v, mSlot);
535                            v.removeOnLayoutChangeListener(this);
536                        }
537                    });
538                } else {
539                   recycle(photo);
540                }
541            } else {
542                log("natural, %s was null!", (mSlot == NEXT ? "next" : "previous"));
543            }
544        }
545    };
546
547    private class PhotoLaunchTask extends AsyncTask<Void, Void, View> {
548        private final BitmapFactory.Options mOptions;
549
550        public PhotoLaunchTask () {
551            mOptions = new BitmapFactory.Options();
552            mOptions.inTempStorage = new byte[32768];
553        }
554
555        @Override
556        public View doInBackground(Void... unused) {
557            log("load a new photo");
558            final PhotoTable table = PhotoTable.this;
559            return applyFrame(PhotoTable.this, mOptions,
560                 table.mPhotoSource.next(mOptions,
561                      table.mLongSide, table.mShortSide));
562        }
563
564        @Override
565        public void onPostExecute(View photo) {
566            if (photo != null) {
567                final PhotoTable table = PhotoTable.this;
568
569                table.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
570                    LayoutParams.WRAP_CONTENT));
571                if (table.hasSelection()) {
572                    for (int slot = 0; slot < mOnDeck.length; slot++) {
573                        if (mOnDeck[slot] != null) {
574                            table.moveToTopOfPile(mOnDeck[slot]);
575                        }
576                    }
577                    table.moveToTopOfPile(table.getSelection());
578                }
579
580                log("drop it");
581                table.throwOnTable(photo);
582
583                if (mOnTable.size() > mTableCapacity) {
584                    int targetSize = Math.max(0, mOnTable.size() - mRedealCount);
585                    while (mOnTable.size() > targetSize) {
586                        fadeAway(mOnTable.poll(), false);
587                    }
588                }
589
590                if(table.mOnTable.size() < table.mTableCapacity) {
591                    table.scheduleNext(table.mFastDropPeriod);
592                }
593            }
594        }
595    };
596
597    /** Bring a new photo onto the table. */
598    public void launch() {
599        log("launching");
600        setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE);
601        if (!hasSelection()) {
602            log("inflate it");
603            if (mPhotoLaunchTask == null ||
604                mPhotoLaunchTask.getStatus() == AsyncTask.Status.FINISHED) {
605                mPhotoLaunchTask = new PhotoLaunchTask();
606                mPhotoLaunchTask.execute();
607            }
608        }
609    }
610
611    /** De-emphasize the other photos on the table. */
612    public void fadeOutBackground(final View photo) {
613        resolveBackgroundQueue();
614        if (mBackgroudOptimization) {
615            mBackground.animate()
616                    .withLayer()
617                    .setDuration(mPickUpDuration)
618                    .alpha(0f);
619        } else {
620            mScrim.setAlpha(0f);
621            mScrim.setVisibility(View.VISIBLE);
622            bringChildToFront(mScrim);
623            bringChildToFront(photo);
624            mScrim.animate()
625                    .withLayer()
626                    .setDuration(mPickUpDuration)
627                    .alpha(1f);
628        }
629    }
630
631
632    /** Return the other photos to foreground status. */
633    public void fadeInBackground(final View photo) {
634        if (mBackgroudOptimization) {
635            mWaitingToJoinBackground.add(photo);
636            mBackground.animate()
637                    .withLayer()
638                    .setDuration(mPickUpDuration)
639                    .alpha(1f)
640                    .withEndAction(new Runnable() {
641                        @Override
642                        public void run() {
643                            resolveBackgroundQueue();
644                        }
645                    });
646        } else {
647            bringChildToFront(mScrim);
648            bringChildToFront(photo);
649            mScrim.animate()
650                    .withLayer()
651                    .setDuration(mPickUpDuration)
652                    .alpha(0f)
653                    .withEndAction(new Runnable() {
654                        @Override
655                        public void run() {
656                            mScrim.setVisibility(View.GONE);
657                        }
658                    });
659        }
660    }
661
662    private void resolveBackgroundQueue() {
663        for(View photo: mWaitingToJoinBackground) {
664              moveToBackground(photo);
665        }
666        mWaitingToJoinBackground.clear();
667    }
668
669    /** Dispose of the photo gracefully, in case we can see some of it. */
670    public void fadeAway(final View photo, final boolean replace) {
671        // fade out of view
672        mOnTable.remove(photo);
673        exitStageLeft(photo);
674        photo.setOnTouchListener(null);
675        photo.animate().cancel();
676        photo.animate()
677                .withLayer()
678                .alpha(0f)
679                .setDuration(mPickUpDuration)
680                .withEndAction(new Runnable() {
681                        @Override
682                        public void run() {
683                            if (photo == getFocus()) {
684                                clearFocus();
685                            }
686                            mStageLeft.removeView(photo);
687                            recycle(photo);
688                            if (replace) {
689                                scheduleNext(mNowDropDelay);
690                            }
691                        }
692                    });
693    }
694
695    /** Visually on top, and also freshest, for the purposes of timeouts. */
696    public void moveToTopOfPile(View photo) {
697        // make this photo the last to be removed.
698        if (isInBackground(photo)) {
699           mBackground.bringChildToFront(photo);
700        } else {
701            bringChildToFront(photo);
702        }
703        invalidate();
704        mOnTable.remove(photo);
705        mOnTable.offer(photo);
706    }
707
708    /** On deck is to the left or right of the selected photo. */
709    private void placeOnDeck(final View photo, final int slot ) {
710        if (slot < mOnDeck.length) {
711            if (mOnDeck[slot] != null && mOnDeck[slot] != photo) {
712                fadeAway(mOnDeck[slot], false);
713            }
714            mOnDeck[slot] = photo;
715            float photoWidth = photo.getWidth();
716            float photoHeight = photo.getHeight();
717            float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth);
718
719            float x = (getWidth() - photoWidth) / 2f;
720            float y = (getHeight() - photoHeight) / 2f;
721
722            float offset = (((float) mWidth + scale * (photoWidth - 2f * mInset)) / 2f);
723            x += (slot == NEXT? 1f : -1f) * offset;
724
725            photo.animate()
726                .withLayer()
727                .rotation(0f)
728                .rotationY(0f)
729                .scaleX(scale)
730                .scaleY(scale)
731                .x(x)
732                .y(y)
733                .setDuration(mPickUpDuration)
734                .setInterpolator(new DecelerateInterpolator(2f));
735        }
736    }
737
738    /** Move in response to touch. */
739    public void move(final View photo, float x, float y, float a) {
740        photo.animate().cancel();
741        photo.setAlpha(1f);
742        photo.setX((int) x);
743        photo.setY((int) y);
744        photo.setRotation((int) a);
745    }
746
747    /** Wind up off screen, so we can animate in. */
748    private void throwOnTable(final View photo) {
749        mOnTable.offer(photo);
750        log("start offscreen");
751        photo.setRotation(mThrowRotation);
752        photo.setX(-mLongSide);
753        photo.setY(-mLongSide);
754
755        dropOnTable(photo, mThrowInterpolator);
756    }
757
758    public void move(final View photo, float dx, float dy, boolean drop) {
759        if (photo != null) {
760            final float x = photo.getX() + dx;
761            final float y = photo.getY() + dy;
762            photo.setX(x);
763            photo.setY(y);
764            Log.d(TAG, "[" + photo.getX() + ", " + photo.getY() + "] + (" + dx + "," + dy + ")");
765            if (drop && photoOffTable(photo)) {
766                fadeAway(photo, true);
767            }
768        }
769    }
770
771    /** Fling with no touch hints, then land off screen. */
772    public void fling(final View photo) {
773        final float[] o = { mWidth + mLongSide / 2f,
774                            mHeight + mLongSide / 2f };
775        final float[] a = { photo.getX(), photo.getY() };
776        final float[] b = { o[0], a[1] + o[0] - a[0] };
777        final float[] c = { a[0] + o[1] - a[1], o[1] };
778        float[] delta = { 0f, 0f };
779        if (Math.hypot(b[0] - a[0], b[1] - a[1]) < Math.hypot(c[0] - a[0], c[1] - a[1])) {
780            delta[0] = b[0] - a[0];
781            delta[1] = b[1] - a[1];
782        } else {
783            delta[0] = c[0] - a[0];
784            delta[1] = c[1] - a[1];
785        }
786
787        final float dist = (float) Math.hypot(delta[0], delta[1]);
788        final int duration = (int) (1000f * dist / mThrowSpeed);
789        fling(photo, delta[0], delta[1], duration, true);
790    }
791
792    /** Continue dynamically after a fling gesture, possibly off the screen. */
793    public void fling(final View photo, float dx, float dy, int duration, boolean spin) {
794        if (photo == getFocus()) {
795            if (moveFocus(photo, 0f) == null) {
796                moveFocus(photo, 180f);
797            }
798        }
799        moveToForeground(photo);
800        ViewPropertyAnimator animator = photo.animate()
801                .withLayer()
802                .xBy(dx)
803                .yBy(dy)
804                .setDuration(duration)
805                .setInterpolator(new DecelerateInterpolator(2f));
806
807        if (spin) {
808            animator.rotation(mThrowRotation);
809        }
810
811        if (photoOffTable(photo, (int) dx, (int) dy)) {
812            log("fling away");
813            animator.withEndAction(new Runnable() {
814                    @Override
815                    public void run() {
816                        fadeAway(photo, true);
817                    }
818                });
819        }
820    }
821    public boolean photoOffTable(final View photo) {
822        return photoOffTable(photo, 0, 0);
823    }
824
825    public boolean photoOffTable(final View photo, final int dx, final int dy) {
826        Rect hit = new Rect();
827        photo.getHitRect(hit);
828        hit.offset(dx, dy);
829        return (hit.bottom < 0f || hit.top > getHeight() ||
830                hit.right < 0f || hit.left > getWidth());
831    }
832
833    /** Animate to a random place and orientation, down on the table (visually small). */
834    public void dropOnTable(final View photo) {
835        dropOnTable(photo, mDropInterpolator);
836    }
837
838    /** Animate to a random place and orientation, down on the table (visually small). */
839    public void dropOnTable(final View photo, final Interpolator interpolator) {
840        float angle = randfrange(-mImageRotationLimit, mImageRotationLimit);
841        PointF p = randMultiDrop(sRNG.nextInt(),
842                                 (float) sRNG.nextGaussian(), (float) sRNG.nextGaussian(),
843                                 mWidth, mHeight);
844        float x = p.x;
845        float y = p.y;
846
847        log("drop it at %f, %f", x, y);
848
849        float x0 = photo.getX();
850        float y0 = photo.getY();
851
852        x -= mLongSide / 2f;
853        y -= mShortSide / 2f;
854        log("fixed offset is %f, %f ", x, y);
855
856        float dx = x - x0;
857        float dy = y - y0;
858
859        float dist = (float) Math.hypot(dx, dy);
860        int duration = (int) (1000f * dist / mThrowSpeed);
861        duration = Math.max(duration, 1000);
862
863        log("animate it");
864        // toss onto table
865        resolveBackgroundQueue();
866        photo.animate()
867            .withLayer()
868            .scaleX(mTableRatio / mImageRatio)
869            .scaleY(mTableRatio / mImageRatio)
870            .rotation(angle)
871            .x(x)
872            .y(y)
873            .setDuration(duration)
874            .setInterpolator(interpolator)
875            .withEndAction(new Runnable() {
876                @Override
877                public void run() {
878                    mWaitingToJoinBackground.add(photo);
879                }
880            });
881    }
882
883    private void moveToBackground(View photo) {
884        if (mBackgroudOptimization && !isInBackground(photo)) {
885            removeViewFromParent(photo);
886            mBackground.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
887                    LayoutParams.WRAP_CONTENT));
888        }
889    }
890
891    private void exitStageLeft(View photo) {
892        removeViewFromParent(photo);
893        mStageLeft.addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
894                LayoutParams.WRAP_CONTENT));
895    }
896
897    private void removeViewFromParent(View photo) {
898        ViewParent parent = photo.getParent();
899        if (parent != null) {  // should never be null, just being paranoid
900            ((ViewGroup) parent).removeView(photo);
901        }
902    }
903
904    private void moveToForeground(View photo) {
905        if (mBackgroudOptimization && isInBackground(photo)) {
906            mBackground.removeView(photo);
907            addView(photo, new LayoutParams(LayoutParams.WRAP_CONTENT,
908                    LayoutParams.WRAP_CONTENT));
909        }
910    }
911
912    private boolean isInBackground(View photo) {
913        return mBackgroudOptimization && mBackground.indexOfChild(photo) != -1;
914    }
915
916    /** wrap all orientations to the interval [-180, 180). */
917    private float wrapAngle(float angle) {
918        float result = angle + 180;
919        result = ((result % 360) + 360) % 360; // catch negative numbers
920        result -= 180;
921        return result;
922    }
923
924    /** Animate the selected photo to the foreground: zooming in to bring it forward. */
925    private void pickUp(final View photo) {
926        float photoWidth = photo.getWidth();
927        float photoHeight = photo.getHeight();
928
929        float scale = Math.min(getHeight() / photoHeight, getWidth() / photoWidth);
930
931        log("scale is %f", scale);
932        log("target it");
933        float x = (getWidth() - photoWidth) / 2f;
934        float y = (getHeight() - photoHeight) / 2f;
935
936        photo.setRotation(wrapAngle(photo.getRotation()));
937
938        log("animate it");
939        // lift up to the glass for a good look
940        mWaitingToJoinBackground.remove(photo);
941        moveToForeground(photo);
942        photo.animate()
943            .withLayer()
944            .rotation(0f)
945            .rotationY(0f)
946            .alpha(1f)
947            .scaleX(scale)
948            .scaleY(scale)
949            .x(x)
950            .y(y)
951            .setDuration(mPickUpDuration)
952            .setInterpolator(new DecelerateInterpolator(2f))
953            .withEndAction(new Runnable() {
954                @Override
955                public void run() {
956                    log("endtimes: %f", photo.getX());
957                }
958            });
959    }
960
961    private Bitmap getBitmap(View photo) {
962        if (photo == null) {
963            return null;
964        }
965        ImageView image = (ImageView) photo;
966        LayerDrawable layers = (LayerDrawable) image.getDrawable();
967        if (layers == null) {
968            return null;
969        }
970        BitmapDrawable bitmap = (BitmapDrawable) layers.getDrawable(0);
971        if (bitmap == null) {
972            return null;
973        }
974        return bitmap.getBitmap();
975    }
976
977    private void recycle(View photo) {
978        if (photo != null) {
979            removeViewFromParent(photo);
980            mPhotoSource.recycle(getBitmap(photo));
981        }
982    }
983
984    public void setHighlight(View photo, boolean highlighted) {
985        ImageView image = (ImageView) photo;
986        LayerDrawable layers = (LayerDrawable) image.getDrawable();
987        if (highlighted) {
988            layers.getDrawable(1).setColorFilter(mHighlightColor, PorterDuff.Mode.SRC_IN);
989        } else {
990            layers.getDrawable(1).clearColorFilter();
991        }
992    }
993
994    /** Schedule the first launch.  Idempotent. */
995    public void start() {
996        if (!mStarted) {
997            log("kick it");
998            mStarted = true;
999            scheduleNext(0);
1000        }
1001    }
1002
1003    public void refreshSelection() {
1004        scheduleSelectionReaper(mMaxFocusTime);
1005    }
1006
1007    public void scheduleSelectionReaper(int delay) {
1008        removeCallbacks(mSelectionReaper);
1009        postDelayed(mSelectionReaper, delay);
1010    }
1011
1012    public void refreshFocus() {
1013        scheduleFocusReaper(mMaxFocusTime);
1014    }
1015
1016    public void scheduleFocusReaper(int delay) {
1017        removeCallbacks(mFocusReaper);
1018        postDelayed(mFocusReaper, delay);
1019    }
1020
1021    public void scheduleNext(int delay) {
1022        removeCallbacks(mLauncher);
1023        postDelayed(mLauncher, delay);
1024    }
1025
1026    private static void log(String message, Object... args) {
1027        if (DEBUG) {
1028            Formatter formatter = new Formatter();
1029            formatter.format(message, args);
1030            Log.i(TAG, formatter.toString());
1031        }
1032    }
1033}
1034