Merge "Import translations. DO NOT MERGE" into jb-ub-mail
diff --git a/res/layout/background.xml b/res/layout/background.xml
index fc067ec..84b03a5 100644
--- a/res/layout/background.xml
+++ b/res/layout/background.xml
@@ -15,15 +15,11 @@
      See the License for the specific language governing permissions and
      limitations under the License.
 -->
-<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+<View xmlns:android="http://schemas.android.com/apk/res/android"
         android:id="@+id/background"
         android:layout_width="match_parent"
         android:minHeight="@dimen/conversation_item_height"
         android:ellipsize="end"
         android:gravity="center_vertical"
         android:paddingLeft="16dip"
-        android:singleLine="true"
-        android:text="@string/no_conversations"
-        android:textColor="@android:color/white"
-        android:background="@color/leaveBehindBackground"
-        android:textSize="16sp" />
\ No newline at end of file
+        android:background="@color/leaveBehindBackground" />
\ No newline at end of file
diff --git a/res/values/animation_constants.xml b/res/values/animation_constants.xml
index 977f942..0bcdf25 100644
--- a/res/values/animation_constants.xml
+++ b/res/values/animation_constants.xml
@@ -26,6 +26,7 @@
     <integer name="dialog_animationShortDur">150</integer>
     <integer name="shrink_animation_duration">350</integer>
     <integer name="slide_animation_duration">350</integer>
+    <integer name="fade_in_animation_duration">350</integer>
 
     <!-- Swipe constants -->
     <integer name="swipe_escape_velocity">100</integer>
diff --git a/src/com/android/mail/browse/ConversationContainer.java b/src/com/android/mail/browse/ConversationContainer.java
index 310a67e..5e0aed5 100644
--- a/src/com/android/mail/browse/ConversationContainer.java
+++ b/src/com/android/mail/browse/ConversationContainer.java
@@ -35,6 +35,7 @@
 import com.android.mail.browse.ScrollNotifier.ScrollListener;
 import com.android.mail.ui.ConversationViewFragment;
 import com.android.mail.utils.DequeMap;
+import com.android.mail.utils.InputSmoother;
 import com.android.mail.utils.LogUtils;
 import com.google.common.collect.Lists;
 
@@ -73,6 +74,12 @@
         R.id.conversation_topmost_overlay
     };
 
+    /**
+     * Maximum scroll speed (in dp/sec) at which the snap header animation will draw.
+     * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect).
+     */
+    private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f;
+
     private ConversationViewAdapter mOverlayAdapter;
     private int[] mOverlayBottoms;
     private ConversationWebView mWebView;
@@ -158,6 +165,8 @@
 
     private boolean mDisableLayoutTracing;
 
+    private final InputSmoother mVelocityTracker;
+
     private final DataSetObserver mAdapterObserver = new AdapterObserver();
 
     /**
@@ -200,6 +209,8 @@
 
         mOverlayViews = new SparseArray<OverlayView>();
 
+        mVelocityTracker = new InputSmoother(c);
+
         mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop();
 
         // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the
@@ -340,6 +351,7 @@
 
     @Override
     public void onNotifierScroll(final int x, final int y) {
+        mVelocityTracker.onInput(y);
         mDisableLayoutTracing = true;
         positionOverlays(x, y);
         mDisableLayoutTracing = false;
@@ -415,21 +427,7 @@
             spacerIndex--;
         }
 
-        // render and/or re-position snap header
-        ConversationOverlayItem snapItem = null;
-        if (mSnapIndex != -1) {
-            final ConversationOverlayItem item = mOverlayAdapter.getItem(mSnapIndex);
-            if (item.canBecomeSnapHeader()) {
-                snapItem = item;
-            }
-        }
-        if (snapItem == null) {
-            mSnapHeader.setVisibility(GONE);
-            mSnapHeader.unbind();
-        } else {
-            snapItem.bindView(mSnapHeader, false /* measureOnly */);
-            mSnapHeader.setVisibility(VISIBLE);
-        }
+        positionSnapHeader(mSnapIndex);
     }
 
     /**
@@ -604,6 +602,9 @@
         final OverlayView overlay = mOverlayViews.get(adapterIndex);
         final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex);
 
+        // save off the item's current top for later snap calculations
+        item.setTop(overlayTopY);
+
         // is the overlay visible and does it have non-zero height?
         if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY
                 && overlayTopY < mOffsetY + getHeight()) {
@@ -684,6 +685,53 @@
         return view;
     }
 
+    // render and/or re-position snap header
+    private void positionSnapHeader(int snapIndex) {
+        ConversationOverlayItem snapItem = null;
+        if (snapIndex != -1) {
+            final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex);
+            if (item.canBecomeSnapHeader()) {
+                snapItem = item;
+            }
+        }
+        if (snapItem == null) {
+            mSnapHeader.setVisibility(GONE);
+            mSnapHeader.unbind();
+            return;
+        }
+
+        snapItem.bindView(mSnapHeader, false /* measureOnly */);
+        mSnapHeader.setVisibility(VISIBLE);
+
+        int overlap = 0;
+
+        final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1);
+        if (next != null) {
+            overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY);
+
+            // disable overlap drawing past a certain speed
+            if (overlap < 0) {
+                final Float v = mVelocityTracker.getSmoothedVelocity();
+                if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) {
+                    overlap = 0;
+                }
+            }
+        }
+        mSnapHeader.setTranslateY(overlap);
+    }
+
+    // find the next header that can push the snap header up
+    private ConversationOverlayItem findNextPushingOverlay(int start) {
+        int value = -1;
+        for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) {
+            final ConversationOverlayItem next = mOverlayAdapter.getItem(i);
+            if (next.canPushSnapHeader()) {
+                return next;
+            }
+        }
+        return null;
+    }
+
     /**
      * Prevents any layouts from happening until the next time {@link #onGeometryChange(int[])} is
      * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
diff --git a/src/com/android/mail/browse/ConversationOverlayItem.java b/src/com/android/mail/browse/ConversationOverlayItem.java
index c996184..71fc772 100644
--- a/src/com/android/mail/browse/ConversationOverlayItem.java
+++ b/src/com/android/mail/browse/ConversationOverlayItem.java
@@ -29,6 +29,7 @@
 
 public abstract class ConversationOverlayItem {
     private int mHeight;  // in px
+    private int mTop;  // in px
     private boolean mNeedsMeasure;
 
     public static final String LOG_TAG = ConversationViewFragment.LAYOUT_TAG;
@@ -80,6 +81,14 @@
         }
     }
 
+    public int getTop() {
+        return mTop;
+    }
+
+    public void setTop(int top) {
+        mTop = top;
+    }
+
     public boolean isMeasurementValid() {
         return !mNeedsMeasure;
     }
diff --git a/src/com/android/mail/browse/HeaderBlock.java b/src/com/android/mail/browse/HeaderBlock.java
deleted file mode 100644
index 6fb934e..0000000
--- a/src/com/android/mail/browse/HeaderBlock.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (C) 2012 Google Inc.
- * Licensed to The Android Open Source Project.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.mail.browse;
-
-/**
- * A header block in the conversation view that corresponds to a region in the web content. It may
- * also be eligible for snapping.
- *
- */
-public interface HeaderBlock {
-
-    /**
-     * Eligible to become a a snappy header?
-     */
-    boolean canSnap();
-    /**
-     * If eligible for snapping, returns the populated header view to snap.
-     */
-    MessageHeaderView getSnapView();
-    /**
-     * Spaces out this view in its container by this number of pixels to match its message body
-     * size, if any.
-     */
-    void setMarginBottom(int height);
-    void setVisibility(int vis);
-    /**
-     * Called on a header when new contact info is known for the conversation. If the header
-     * displays contact info, it should refresh it.
-     */
-    void updateContactInfo();
-    /**
-     * If this header can be starred/unstarred, change whether the message header appears to be
-     * starred or not. Does not actually mark the backing message starred/unstarred.
-     */
-    void setStarDisplay(boolean starred);
-
-}
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index 18ae53f..58e7cd7 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -62,7 +62,7 @@
 import java.util.Map;
 
 public class MessageHeaderView extends LinearLayout implements OnClickListener,
-        OnMenuItemClickListener, HeaderBlock, ConversationContainer.DetachListener {
+        OnMenuItemClickListener, ConversationContainer.DetachListener {
 
     /**
      * Cap very long recipient lists during summary construction for efficiency.
@@ -277,16 +277,6 @@
         return mMessageHeaderItem == null || mMessageHeaderItem.isExpanded();
     }
 
-    @Override
-    public boolean canSnap() {
-        return isExpanded();
-    }
-
-    @Override
-    public MessageHeaderView getSnapView() {
-        return this;
-    }
-
     public void setSnappy(boolean snappy) {
         mIsSnappy = snappy;
         hideMessageDetails();
@@ -297,16 +287,6 @@
         }
     }
 
-    /**
-     * Check if this header's displayed data matches that of another header.
-     *
-     * @param other another header
-     * @return true if the headers are displaying data for the same message
-     */
-    public boolean matches(MessageHeaderView other) {
-        return other != null && mMessage != null && mMessage.equals(other.mMessage);
-    }
-
     @Override
     public void onDetachedFromParent() {
         unbind();
@@ -328,24 +308,6 @@
         }
     }
 
-    public void renderUpperHeaderFrom(MessageHeaderView other) {
-        mMessageHeaderItem = other.mMessageHeaderItem;
-        mMessage = other.mMessage;
-        mSender = other.mSender;
-        mDefaultReplyAll = other.mDefaultReplyAll;
-
-        mSenderNameView.setText(other.mSenderNameView.getText());
-        mSenderEmailView.setText(other.mSenderEmailView.getText());
-        mStarView.setSelected(other.mStarView.isSelected());
-        mStarView.setContentDescription(getResources().getString(
-                mStarView.isSelected() ? R.string.remove_star : R.string.add_star));
-
-        updateContactInfo();
-
-        mIsDraft = other.mIsDraft;
-        updateChildVisibility();
-    }
-
     public void initialize(FormattedDateBuilder dateBuilder, Account account,
             Map<String, Address> addressCache) {
         mDateBuilder = dateBuilder;
@@ -662,23 +624,6 @@
         findViewById(rowRes).setVisibility(VISIBLE);
     }
 
-    @Override
-    public void setMarginBottom(int bottomMargin) {
-        MarginLayoutParams p = (MarginLayoutParams) getLayoutParams();
-        if (p.bottomMargin != bottomMargin) {
-            p.bottomMargin = bottomMargin;
-            setLayoutParams(p);
-        }
-    }
-
-    public void setMarginTop(int topMargin) {
-        MarginLayoutParams p = (MarginLayoutParams) getLayoutParams();
-        if (p.topMargin != topMargin) {
-            p.topMargin = topMargin;
-            setLayoutParams(p);
-        }
-    }
-
     public void setTranslateY(int offsetY) {
         if (mDrawTranslateY != offsetY) {
             mDrawTranslateY = offsetY;
@@ -773,8 +718,7 @@
         return builder.build();
     }
 
-    @Override
-    public void updateContactInfo() {
+    private void updateContactInfo() {
 
         mPresenceView.setImageDrawable(null);
         mPresenceView.setVisibility(GONE);
@@ -971,13 +915,6 @@
         setMessageDetailsVisibility(GONE);
     }
 
-    @Override
-    public void setStarDisplay(boolean starred) {
-        if (mStarView.isSelected() != starred) {
-            mStarView.setSelected(starred);
-        }
-    }
-
     private void hideCollapsedDetails() {
         if (mCollapsedDetailsView != null) {
             mCollapsedDetailsView.setVisibility(GONE);
diff --git a/src/com/android/mail/browse/SuperCollapsedBlock.java b/src/com/android/mail/browse/SuperCollapsedBlock.java
index 2abec7a..e97cea8 100644
--- a/src/com/android/mail/browse/SuperCollapsedBlock.java
+++ b/src/com/android/mail/browse/SuperCollapsedBlock.java
@@ -29,15 +29,13 @@
 import com.android.mail.R;
 import com.android.mail.browse.ConversationViewAdapter.SuperCollapsedBlockItem;
 import com.android.mail.utils.LogTag;
-import com.android.mail.utils.LogUtils;
 
 /**
  * A header block that expands to a list of collapsed message headers. Will notify a listener on tap
  * so the listener can hide the block and reveal the corresponding collapsed message headers.
  *
  */
-public class SuperCollapsedBlock extends FrameLayout implements View.OnClickListener,
-        HeaderBlock {
+public class SuperCollapsedBlock extends FrameLayout implements View.OnClickListener {
 
     public interface OnClickListener {
         /**
@@ -118,34 +116,4 @@
                 + r.getDimensionPixelOffset(R.dimen.message_header_vertical_margin);
     }
 
-    @Override
-    public boolean canSnap() {
-        return false;
-    }
-
-    @Override
-    public MessageHeaderView getSnapView() {
-        return null;
-    }
-
-    @Override
-    public void setMarginBottom(int height) {
-        // no-op. should never have a matching body.
-
-        // sanity check
-        if (height != 0) {
-            LogUtils.d(LOG_TAG, "super-collapsed block yielded unexpected body height: %d", height);
-        }
-    }
-
-    @Override
-    public void updateContactInfo() {
-        // no-op
-    }
-
-    @Override
-    public void setStarDisplay(boolean starred) {
-        // no-op
-    }
-
 }
diff --git a/src/com/android/mail/browse/SwipeableConversationItemView.java b/src/com/android/mail/browse/SwipeableConversationItemView.java
index 6e736e7..a8c8625 100644
--- a/src/com/android/mail/browse/SwipeableConversationItemView.java
+++ b/src/com/android/mail/browse/SwipeableConversationItemView.java
@@ -23,7 +23,6 @@
 import android.view.View;
 import android.widget.FrameLayout;
 import android.widget.ListView;
-import android.widget.TextView;
 
 import com.android.mail.R;
 import com.android.mail.providers.Conversation;
@@ -36,7 +35,7 @@
 public class SwipeableConversationItemView extends FrameLayout {
 
     private ConversationItemView mConversationItemView;
-    private TextView mBackground;
+    private View mBackground;
 
     public SwipeableConversationItemView(Context context, String account) {
         super(context);
@@ -44,14 +43,12 @@
         addView(mConversationItemView);
     }
 
-    public void addBackground(Context context, String text) {
-        mBackground = (TextView) findViewById(R.id.background);
+    public void addBackground(Context context) {
+        mBackground = findViewById(R.id.background);
         if (mBackground == null) {
-            mBackground = (TextView) LayoutInflater.from(context).inflate(R.layout.background,
-                    null, true);
+            mBackground = LayoutInflater.from(context).inflate(R.layout.background, null, true);
             addView(mBackground, 0);
         }
-        mBackground.setText(text);
     }
 
     public void setBackgroundVisibility(int visibility) {
@@ -87,10 +84,9 @@
                 priorityArrowsEnabled, animatedAdapter);
     }
 
-    public void startUndoAnimation(String actionText, ViewMode viewMode, AnimatedAdapter listener,
-            boolean swipe) {
+    public void startUndoAnimation(ViewMode viewMode, AnimatedAdapter listener, boolean swipe) {
         if (swipe) {
-            addBackground(getContext(), actionText);
+            addBackground(getContext());
             setBackgroundVisibility(View.VISIBLE);
             mConversationItemView.startSwipeUndoAnimation(viewMode, listener);
         } else {
diff --git a/src/com/android/mail/ui/AnimatedAdapter.java b/src/com/android/mail/ui/AnimatedAdapter.java
index fb16677..8d46ac9 100644
--- a/src/com/android/mail/ui/AnimatedAdapter.java
+++ b/src/com/android/mail/ui/AnimatedAdapter.java
@@ -297,7 +297,9 @@
         if (hasLeaveBehinds()) {
             Conversation conv = new Conversation((ConversationCursor) getItem(position));
             if(isPositionLeaveBehind(conv)) {
-                return getLeaveBehindItem(conv);
+                LeaveBehindItem fadeIn = getLeaveBehindItem(conv);
+                fadeIn.startFadeInAnimation();
+                return fadeIn;
             }
         }
         if (convertView != null && !(convertView instanceof SwipeableConversationItemView)) {
@@ -396,8 +398,7 @@
             // The undo animation consists of fading in the conversation that
             // had been destroyed.
             undoView = newConversationItemView(position, parent, conversation);
-            undoView.startUndoAnimation(mListView.getSwipeActionText(), mActivity.getViewMode(),
-                    this, swipe);
+            undoView.startUndoAnimation(mActivity.getViewMode(), this, swipe);
         }
         return undoView;
     }
diff --git a/src/com/android/mail/ui/LeaveBehindItem.java b/src/com/android/mail/ui/LeaveBehindItem.java
index f37f200..8e29e53 100644
--- a/src/com/android/mail/ui/LeaveBehindItem.java
+++ b/src/com/android/mail/ui/LeaveBehindItem.java
@@ -45,7 +45,9 @@
     private Account mAccount;
     private AnimatedAdapter mAdapter;
     private ConversationCursor mConversationCursor;
+    private TextView mText;
     private static int sShrinkAnimationDuration = -1;
+    private static int sFadeInAnimationDuration = -1;
 
     public LeaveBehindItem(Context context) {
         this(context, null);
@@ -60,6 +62,8 @@
         if (sShrinkAnimationDuration == -1) {
             sShrinkAnimationDuration = context.getResources().getInteger(
                     R.integer.shrink_animation_duration);
+            sFadeInAnimationDuration = context.getResources().getInteger(
+                    R.integer.fade_in_animation_duration);
         }
     }
 
@@ -89,7 +93,8 @@
         mAdapter = adapter;
         mConversationCursor = (ConversationCursor) adapter.getCursor();
         setData(target);
-        ((TextView) findViewById(R.id.undo_descriptionview)).setText(Html.fromHtml(mUndoOp
+        mText = ((TextView) findViewById(R.id.undo_descriptionview));
+        mText.setText(Html.fromHtml(mUndoOp
                 .getSingularDescription(getContext(), folder)));
         findViewById(R.id.undo_text).setOnClickListener(this);
         findViewById(R.id.undo_icon).setOnClickListener(this);
@@ -170,6 +175,7 @@
     private int mAnimatedHeight = -1;
     private int mWidth;
     private boolean mAnimating;
+    private boolean mFadingInText;
 
     /**
      * Start the animation on an animating view.
@@ -195,6 +201,19 @@
         }
     }
 
+
+    public void startFadeInAnimation() {
+        if (!mFadingInText) {
+            mFadingInText = true;
+            final float start = 0;
+            final float end = 1.0f;
+            ObjectAnimator fadeIn = ObjectAnimator.ofFloat(mText, "alpha", start, end);
+            fadeIn.setInterpolator(new DecelerateInterpolator(2.0f));
+            fadeIn.setDuration(sFadeInAnimationDuration);
+            fadeIn.start();
+        }
+    }
+
     public void setData(Conversation conversation) {
         mData = conversation;
     }
diff --git a/src/com/android/mail/ui/SwipeableListView.java b/src/com/android/mail/ui/SwipeableListView.java
index 16792e6..4841242 100644
--- a/src/com/android/mail/ui/SwipeableListView.java
+++ b/src/com/android/mail/ui/SwipeableListView.java
@@ -222,7 +222,7 @@
             view = (SwipeableConversationItemView) v.getParent();
         }
         if (view != null) {
-            view.addBackground(getContext(), getSwipeActionText());
+            view.addBackground(getContext());
             view.setBackgroundVisibility(View.VISIBLE);
         }
     }
@@ -306,15 +306,4 @@
         commitDestructiveActions();
         return handled;
     }
-
-    /**
-     * Get the text resource corresponding to the result of a swipe.
-     */
-    public String getSwipeActionText() {
-        Resources res = getContext().getResources();
-        if (mSwipeAction == R.id.remove_folder) {
-            return res.getString(R.string.remove_folder, mFolder.name);
-        }
-        return res.getString(mSwipeAction == R.id.archive ? R.string.archive : R.string.delete);
-    }
 }
diff --git a/src/com/android/mail/utils/InputSmoother.java b/src/com/android/mail/utils/InputSmoother.java
new file mode 100644
index 0000000..eaf1e45
--- /dev/null
+++ b/src/com/android/mail/utils/InputSmoother.java
@@ -0,0 +1,100 @@
+package com.android.mail.utils;
+
+import android.content.Context;
+import android.os.SystemClock;
+
+import com.google.common.collect.Lists;
+
+import java.util.Deque;
+
+/**
+ * Utility class to calculate a velocity using a moving average filter of recent input positions.
+ * Intended to smooth out touch input events.
+ */
+public class InputSmoother {
+
+    /**
+     * Some devices have significant sampling noise: it could be that samples come in too late,
+     * or that the reported position doesn't quite match up with the time. Instantaneous velocity
+     * on these devices is too jittery to be useful in deciding whether to instantly snap, so smooth
+     * out the data using a moving average over this window size. A sample window size n will
+     * effectively average the velocity over n-1 points, so n=2 is the minimum valid value (no
+     * averaging at all).
+     */
+    private static final int SAMPLING_WINDOW_SIZE = 5;
+
+    /**
+     * The maximum elapsed time (in millis) between samples that we would consider "consecutive".
+     * Only consecutive samples will factor into the rolling average sample window.
+     * Any samples that are older than this maximum are continually purged from the sample window,
+     * so as to avoid skewing the average with irrelevant older values.
+     */
+    private static final long MAX_SAMPLE_INTERVAL_MS = 200;
+
+    /**
+     * Sampling window to calculate rolling average of scroll velocity.
+     */
+    private final Deque<Sample> mRecentSamples = Lists.newLinkedList();
+    private final float mDensity;
+
+    private static class Sample {
+        int pos;
+        long millis;
+    }
+
+    public InputSmoother(Context context) {
+        mDensity = context.getResources().getDisplayMetrics().density;
+    }
+
+    public void onInput(int pos) {
+        Sample sample;
+        final long nowMs = SystemClock.uptimeMillis();
+
+        final Sample last = mRecentSamples.peekLast();
+        if (last != null && nowMs - last.millis > MAX_SAMPLE_INTERVAL_MS) {
+            mRecentSamples.clear();
+        }
+
+        if (mRecentSamples.size() == SAMPLING_WINDOW_SIZE) {
+            sample = mRecentSamples.removeFirst();
+        } else {
+            sample = new Sample();
+        }
+        sample.pos = pos;
+        sample.millis = nowMs;
+
+        mRecentSamples.add(sample);
+    }
+
+    /**
+     * Calculates velocity based on recent inputs from {@link #onInput(int)}, averaged together to
+     * smooth out jitter.
+     *
+     * @return returns velocity in dp/s, or null if not enough samples have been collected
+     */
+    public Float getSmoothedVelocity() {
+        if (mRecentSamples.size() < 2) {
+            // need at least 2 position samples to determine a velocity
+            return null;
+        }
+
+        // calculate moving average over current window
+        int totalDistancePx = 0;
+        int prevPos = mRecentSamples.getFirst().pos;
+        final long totalTimeMs = mRecentSamples.getLast().millis - mRecentSamples.getFirst().millis;
+
+        if (totalTimeMs <= 0) {
+            // samples are really fast or bad. no answer.
+            return null;
+        }
+
+        for (Sample s : mRecentSamples) {
+            totalDistancePx += Math.abs(s.pos - prevPos);
+            prevPos = s.pos;
+        }
+        final float distanceDp = totalDistancePx / mDensity;
+        // velocity in dp per second
+        return distanceDp * 1000 / totalTimeMs;
+    }
+
+}