diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index d6487cb0ca4e92c4824c7700c75555c1ca9e6724..08217326a0efa281c641fdabb33dcf30190b6f3d 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -428,7 +428,7 @@ public final class LatinIME extends InputMethodService implements KeyboardAction
         initSuggest();
 
         if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.getInstance().init(this);
+            ResearchLogger.getInstance().init(this, mKeyboardSwitcher);
         }
         mDisplayOrientation = getResources().getConfiguration().orientation;
 
diff --git a/java/src/com/android/inputmethod/research/LogStatement.java b/java/src/com/android/inputmethod/research/LogStatement.java
new file mode 100644
index 0000000000000000000000000000000000000000..090c58e2718b034fa5fff0796a98b716126d143a
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogStatement.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2013 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.inputmethod.research;
+
+/**
+ * A template for typed information stored in the logs.
+ *
+ * A LogStatement contains a name, keys, and flags about whether the {@code Object[] values}
+ * associated with the {@code String[] keys} are likely to reveal information about the user.  The
+ * actual values are stored separately.
+ */
+class LogStatement {
+    // Constants for particular statements
+    public static final String TYPE_POINTER_TRACKER_CALL_LISTENER_ON_CODE_INPUT =
+            "PointerTrackerCallListenerOnCodeInput";
+    public static final String KEY_CODE = "code";
+    public static final String VALUE_RESEARCH = "research";
+    public static final String TYPE_LATIN_KEYBOARD_VIEW_ON_LONG_PRESS =
+            "LatinKeyboardViewOnLongPress";
+    public static final String ACTION = "action";
+    public static final String VALUE_DOWN = "DOWN";
+    public static final String TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS =
+            "LatinKeyboardViewProcessMotionEvents";
+    public static final String KEY_LOGGING_RELATED = "loggingRelated";
+
+    // Name specifying the LogStatement type.
+    private final String mType;
+
+    // mIsPotentiallyPrivate indicates that event contains potentially private information.  If
+    // the word that this event is a part of is determined to be privacy-sensitive, then this
+    // event should not be included in the output log.  The system waits to output until the
+    // containing word is known.
+    private final boolean mIsPotentiallyPrivate;
+
+    // mIsPotentiallyRevealing indicates that this statement may disclose details about other
+    // words typed in other LogUnits.  This can happen if the user is not inserting spaces, and
+    // data from Suggestions and/or Composing text reveals the entire "megaword".  For example,
+    // say the user is typing "for the win", and the system wants to record the bigram "the
+    // win".  If the user types "forthe", omitting the space, the system will give "for the" as
+    // a suggestion.  If the user accepts the autocorrection, the suggestion for "for the" is
+    // included in the log for the word "the", disclosing that the previous word had been "for".
+    // For now, we simply do not include this data when logging part of a "megaword".
+    private final boolean mIsPotentiallyRevealing;
+
+    // mKeys stores the names that are the attributes in the output json objects
+    private final String[] mKeys;
+    private static final String[] NULL_KEYS = new String[0];
+
+    LogStatement(final String name, final boolean isPotentiallyPrivate,
+            final boolean isPotentiallyRevealing, final String... keys) {
+        mType = name;
+        mIsPotentiallyPrivate = isPotentiallyPrivate;
+        mIsPotentiallyRevealing = isPotentiallyRevealing;
+        mKeys = (keys == null) ? NULL_KEYS : keys;
+    }
+
+    public String getType() {
+        return mType;
+    }
+
+    public boolean isPotentiallyPrivate() {
+        return mIsPotentiallyPrivate;
+    }
+
+    public boolean isPotentiallyRevealing() {
+        return mIsPotentiallyRevealing;
+    }
+
+    public String[] getKeys() {
+        return mKeys;
+    }
+
+    /**
+     * Utility function to test whether a key-value pair exists in a LogStatement.
+     *
+     * A LogStatement is really just a template -- it does not contain the values, only the
+     * keys.  So the values must be passed in as an argument.
+     *
+     * @param queryKey the String that is tested by {@code String.equals()} to the keys in the
+     * LogStatement
+     * @param queryValue an Object that must be {@code Object.equals()} to the key's corresponding
+     * value in the {@code values} array
+     * @param values the values corresponding to mKeys
+     *
+     * @returns {@true} if {@code queryKey} exists in the keys for this LogStatement, and {@code
+     * queryValue} matches the corresponding value in {@code values}
+     *
+     * @throws IllegalArgumentException if {@code values.length} is not equal to keys().length()
+     */
+    public boolean containsKeyValuePair(final String queryKey, final Object queryValue,
+            final Object[] values) {
+        if (mKeys.length != values.length) {
+            throw new IllegalArgumentException("Mismatched number of keys and values.");
+        }
+        final int length = mKeys.length;
+        for (int i = 0; i < length; i++) {
+            if (mKeys[i].equals(queryKey) && values[i].equals(queryValue)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Utility function to set a value in a LogStatement.
+     *
+     * A LogStatement is really just a template -- it does not contain the values, only the
+     * keys.  So the values must be passed in as an argument.
+     *
+     * @param queryKey the String that is tested by {@code String.equals()} to the keys in the
+     * LogStatement
+     * @param values the array of values corresponding to mKeys
+     * @param newValue the replacement value to go into the {@code values} array
+     *
+     * @returns {@true} if the key exists and the value was successfully set, {@false} otherwise
+     *
+     * @throws IllegalArgumentException if {@code values.length} is not equal to keys().length()
+     */
+    public boolean setValue(final String queryKey, final Object[] values, final Object newValue) {
+        if (mKeys.length != values.length) {
+            throw new IllegalArgumentException("Mismatched number of keys and values.");
+        }
+        final int length = mKeys.length;
+        for (int i = 0; i < length; i++) {
+            if (mKeys[i].equals(queryKey)) {
+                values[i] = newValue;
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java
index 638b7d9d49f7441f7822af94235d28f66d94d898..608fab3f11d9af9f6c15bf04c6772de7310f1791 100644
--- a/java/src/com/android/inputmethod/research/LogUnit.java
+++ b/java/src/com/android/inputmethod/research/LogUnit.java
@@ -26,15 +26,12 @@ import android.view.inputmethod.CompletionInfo;
 import com.android.inputmethod.keyboard.Key;
 import com.android.inputmethod.latin.SuggestedWords;
 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
-import com.android.inputmethod.latin.Utils;
 import com.android.inputmethod.latin.define.ProductionFlag;
-import com.android.inputmethod.research.ResearchLogger.LogStatement;
 
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
 
 /**
  * A group of log statements related to each other.
@@ -53,6 +50,7 @@ import java.util.Map;
 /* package */ class LogUnit {
     private static final String TAG = LogUnit.class.getSimpleName();
     private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG;
+
     private final ArrayList<LogStatement> mLogStatementList;
     private final ArrayList<Object[]> mValuesList;
     // Assume that mTimeList is sorted in increasing order.  Do not insert null values into
@@ -142,10 +140,10 @@ import java.util.Map;
             JsonWriter jsonWriter = null;
             for (int i = 0; i < size; i++) {
                 final LogStatement logStatement = mLogStatementList.get(i);
-                if (!canIncludePrivateData && logStatement.mIsPotentiallyPrivate) {
+                if (!canIncludePrivateData && logStatement.isPotentiallyPrivate()) {
                     continue;
                 }
-                if (mIsPartOfMegaword && logStatement.mIsPotentiallyRevealing) {
+                if (mIsPartOfMegaword && logStatement.isPotentiallyRevealing()) {
                     continue;
                 }
                 // Only retrieve the jsonWriter if we need to.  If we don't get this far, then
@@ -228,16 +226,16 @@ import java.util.Map;
     private boolean outputLogStatementToLocked(final JsonWriter jsonWriter,
             final LogStatement logStatement, final Object[] values, final Long time) {
         if (DEBUG) {
-            if (logStatement.mKeys.length != values.length) {
-                Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.mName);
+            if (logStatement.getKeys().length != values.length) {
+                Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.getType());
             }
         }
         try {
             jsonWriter.beginObject();
             jsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis());
             jsonWriter.name(UPTIME_KEY).value(time);
-            jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.mName);
-            final String[] keys = logStatement.mKeys;
+            jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.getType());
+            final String[] keys = logStatement.getKeys();
             final int length = values.length;
             for (int i = 0; i < length; i++) {
                 jsonWriter.name(keys[i]);
@@ -261,8 +259,8 @@ import java.util.Map;
                 } else if (value == null) {
                     jsonWriter.nullValue();
                 } else {
-                    Log.w(TAG, "Unrecognized type to be logged: " +
-                            (value == null ? "<null>" : value.getClass().getName()));
+                    Log.w(TAG, "Unrecognized type to be logged: "
+                            + (value == null ? "<null>" : value.getClass().getName()));
                     jsonWriter.nullValue();
                 }
             }
@@ -422,4 +420,123 @@ import java.util.Map;
         }
         return false;
     }
+
+    /**
+     * Remove data associated with selecting the Research button.
+     *
+     * A LogUnit will capture all user interactions with the IME, including the "meta-interactions"
+     * of using the Research button to control the logging (e.g. by starting and stopping recording
+     * of a test case).  Because meta-interactions should not be part of the normal log, calling
+     * this method will set a field in the LogStatements of the motion events to indiciate that
+     * they should be disregarded.
+     *
+     * This implementation assumes that the data recorded by the meta-interaction takes the
+     * form of all events following the first MotionEvent.ACTION_DOWN before the first long-press
+     * before the last onCodeEvent containing a code matching {@code LogStatement.VALUE_RESEARCH}.
+     *
+     * @returns true if data was removed
+     */
+    public boolean removeResearchButtonInvocation() {
+        // This method is designed to be idempotent.
+
+        // First, find last invocation of "research" key
+        final int indexOfLastResearchKey = findLastIndexContainingKeyValue(
+                LogStatement.TYPE_POINTER_TRACKER_CALL_LISTENER_ON_CODE_INPUT,
+                LogStatement.KEY_CODE, LogStatement.VALUE_RESEARCH);
+        if (indexOfLastResearchKey < 0) {
+            // Could not find invocation of "research" key.  Leave log as is.
+            if (DEBUG) {
+                Log.d(TAG, "Could not find research key");
+            }
+            return false;
+        }
+
+        // Look for the long press that started the invocation of the research key code input.
+        final int indexOfLastLongPressBeforeResearchKey =
+                findLastIndexBefore(LogStatement.TYPE_LATIN_KEYBOARD_VIEW_ON_LONG_PRESS,
+                        indexOfLastResearchKey);
+
+        // Look for DOWN event preceding the long press
+        final int indexOfLastDownEventBeforeLongPress =
+                findLastIndexContainingKeyValueBefore(
+                        LogStatement.TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS,
+                        LogStatement.ACTION, LogStatement.VALUE_DOWN,
+                        indexOfLastLongPressBeforeResearchKey);
+
+        // Flag all LatinKeyboardViewProcessMotionEvents from the DOWN event to the research key as
+        // logging-related
+        final int startingIndex = indexOfLastDownEventBeforeLongPress == -1 ? 0
+                : indexOfLastDownEventBeforeLongPress;
+        for (int index = startingIndex; index < indexOfLastResearchKey; index++) {
+            final LogStatement logStatement = mLogStatementList.get(index);
+            final String type = logStatement.getType();
+            final Object[] values = mValuesList.get(index);
+            if (type.equals(LogStatement.TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS)) {
+                logStatement.setValue(LogStatement.KEY_LOGGING_RELATED, values, true);
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Find the index of the last LogStatement before {@code startingIndex} of type {@code type}.
+     *
+     * @param queryType a String that must be {@code String.equals()} to the LogStatement type
+     * @param startingIndex the index to start the backward search from.  Must be less than the
+     * length of mLogStatementList, or an IndexOutOfBoundsException is thrown.  Can be negative,
+     * in which case -1 is returned.
+     *
+     * @return The index of the last LogStatement, -1 if none exists.
+     */
+    private int findLastIndexBefore(final String queryType, final int startingIndex) {
+        return findLastIndexContainingKeyValueBefore(queryType, null, null, startingIndex);
+    }
+
+    /**
+     * Find the index of the last LogStatement before {@code startingIndex} of type {@code type}
+     * containing the given key-value pair.
+     *
+     * @param queryType a String that must be {@code String.equals()} to the LogStatement type
+     * @param queryKey a String that must be {@code String.equals()} to a key in the LogStatement
+     * @param queryValue an Object that must be {@code String.equals()} to the key's corresponding
+     * value
+     *
+     * @return The index of the last LogStatement, -1 if none exists.
+     */
+    private int findLastIndexContainingKeyValue(final String queryType, final String queryKey,
+            final Object queryValue) {
+        return findLastIndexContainingKeyValueBefore(queryType, queryKey, queryValue,
+                mLogStatementList.size() - 1);
+    }
+
+    /**
+     * Find the index of the last LogStatement before {@code startingIndex} of type {@code type}
+     * containing the given key-value pair.
+     *
+     * @param queryType a String that must be {@code String.equals()} to the LogStatement type
+     * @param queryKey a String that must be {@code String.equals()} to a key in the LogStatement
+     * @param queryValue an Object that must be {@code String.equals()} to the key's corresponding
+     * value
+     * @param startingIndex the index to start the backward search from.  Must be less than the
+     * length of mLogStatementList, or an IndexOutOfBoundsException is thrown.  Can be negative,
+     * in which case -1 is returned.
+     *
+     * @return The index of the last LogStatement, -1 if none exists.
+     */
+    private int findLastIndexContainingKeyValueBefore(final String queryType, final String queryKey,
+            final Object queryValue, final int startingIndex) {
+        if (startingIndex < 0) {
+            return -1;
+        }
+        for (int index = startingIndex; index >= 0; index--) {
+            final LogStatement logStatement = mLogStatementList.get(index);
+            final String type = logStatement.getType();
+            if (type.equals(queryType) && (queryKey == null
+                    || logStatement.containsKeyValuePair(queryKey, queryValue,
+                            mValuesList.get(index)))) {
+                return index;
+            }
+        }
+        return -1;
+    }
 }
diff --git a/java/src/com/android/inputmethod/research/MotionEventReader.java b/java/src/com/android/inputmethod/research/MotionEventReader.java
new file mode 100644
index 0000000000000000000000000000000000000000..36e75be1c9d7ddee2a5d0495cd5a664be7812c37
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/MotionEventReader.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2013 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.inputmethod.research;
+
+import android.util.JsonReader;
+import android.util.Log;
+import android.view.MotionEvent;
+
+import com.android.inputmethod.latin.define.ProductionFlag;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+
+public class MotionEventReader {
+    private static final String TAG = MotionEventReader.class.getSimpleName();
+    private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG;
+
+    public ReplayData readMotionEventData(final File file) {
+        final ReplayData replayData = new ReplayData();
+        try {
+            // Read file
+            final JsonReader jsonReader = new JsonReader(new BufferedReader(new InputStreamReader(
+                    new FileInputStream(file))));
+            jsonReader.beginArray();
+            while (jsonReader.hasNext()) {
+                readLogStatement(jsonReader, replayData);
+            }
+            jsonReader.endArray();
+        } catch (FileNotFoundException e) {
+            e.printStackTrace();
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+        return replayData;
+    }
+
+    static class ReplayData {
+        final ArrayList<Integer> mActions = new ArrayList<Integer>();
+        final ArrayList<Integer> mXCoords = new ArrayList<Integer>();
+        final ArrayList<Integer> mYCoords = new ArrayList<Integer>();
+        final ArrayList<Long> mTimes = new ArrayList<Long>();
+    }
+
+    private void readLogStatement(final JsonReader jsonReader, final ReplayData replayData)
+            throws IOException {
+        String logStatementType = null;
+        Integer actionType = null;
+        Integer x = null;
+        Integer y = null;
+        Long time = null;
+        boolean loggingRelated = false;
+
+        jsonReader.beginObject();
+        while (jsonReader.hasNext()) {
+            final String key = jsonReader.nextName();
+            if (key.equals("_ty")) {
+                logStatementType = jsonReader.nextString();
+            } else if (key.equals("_ut")) {
+                time = jsonReader.nextLong();
+            } else if (key.equals("x")) {
+                x = jsonReader.nextInt();
+            } else if (key.equals("y")) {
+                y = jsonReader.nextInt();
+            } else if (key.equals("action")) {
+                final String s = jsonReader.nextString();
+                if (s.equals("UP")) {
+                    actionType = MotionEvent.ACTION_UP;
+                } else if (s.equals("DOWN")) {
+                    actionType = MotionEvent.ACTION_DOWN;
+                } else if (s.equals("MOVE")) {
+                    actionType = MotionEvent.ACTION_MOVE;
+                }
+            } else if (key.equals("loggingRelated")) {
+                loggingRelated = jsonReader.nextBoolean();
+            } else {
+                if (DEBUG) {
+                    Log.w(TAG, "Unknown JSON key in LogStatement: " + key);
+                }
+                jsonReader.skipValue();
+            }
+        }
+        jsonReader.endObject();
+
+        if (logStatementType != null && time != null && x != null && y != null && actionType != null
+                && logStatementType.equals("MainKeyboardViewProcessMotionEvent")
+                && !loggingRelated) {
+            replayData.mActions.add(actionType);
+            replayData.mXCoords.add(x);
+            replayData.mYCoords.add(y);
+            replayData.mTimes.add(time);
+        }
+    }
+
+}
diff --git a/java/src/com/android/inputmethod/research/Replayer.java b/java/src/com/android/inputmethod/research/Replayer.java
new file mode 100644
index 0000000000000000000000000000000000000000..4cc2a5814f91a3136db3763a71e13699d7b6cd2f
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/Replayer.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2012 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.inputmethod.research;
+
+import android.os.Handler;
+import android.os.Message;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.MotionEvent;
+
+import com.android.inputmethod.keyboard.KeyboardSwitcher;
+import com.android.inputmethod.keyboard.MainKeyboardView;
+import com.android.inputmethod.latin.define.ProductionFlag;
+import com.android.inputmethod.research.MotionEventReader.ReplayData;
+
+/**
+ * Replays a sequence of motion events in realtime on the screen.
+ *
+ * Useful for user inspection of logged data.
+ */
+public class Replayer {
+    private static final String TAG = Replayer.class.getSimpleName();
+    private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG;
+    private static final long START_TIME_DELAY_MS = 500;
+
+    private boolean mIsReplaying = false;
+    private KeyboardSwitcher mKeyboardSwitcher;
+
+    public void setKeyboardSwitcher(final KeyboardSwitcher keyboardSwitcher) {
+        mKeyboardSwitcher = keyboardSwitcher;
+    }
+
+    private static final int MSG_MOTION_EVENT = 0;
+    private static final int MSG_DONE = 1;
+    private static final int COMPLETION_TIME_MS = 500;
+
+    // TODO: Support historical events and multi-touch.
+    public void replay(final ReplayData replayData) {
+        if (mIsReplaying) {
+            return;
+        }
+
+        mIsReplaying = true;
+        final int numActions = replayData.mActions.size();
+        if (DEBUG) {
+            Log.d(TAG, "replaying " + numActions + " actions");
+        }
+        if (numActions == 0) {
+            mIsReplaying = false;
+            return;
+        }
+        final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView();
+
+        // The reference time relative to the times stored in events.
+        final long origStartTime = replayData.mTimes.get(0);
+        // The reference time relative to which events are replayed in the present.
+        final long currentStartTime = SystemClock.uptimeMillis() + START_TIME_DELAY_MS;
+        // The adjustment needed to translate times from the original recorded time to the current
+        // time.
+        final long timeAdjustment = currentStartTime - origStartTime;
+        final Handler handler = new Handler() {
+            // Track the time of the most recent DOWN event, to be passed as a parameter when
+            // constructing a MotionEvent.  It's initialized here to the origStartTime, but this is
+            // only a precaution.  The value should be overwritten by the first ACTION_DOWN event
+            // before the first use of the variable.  Note that this may cause the first few events
+            // to have incorrect {@code downTime}s.
+            private long mOrigDownTime = origStartTime;
+
+            @Override
+            public void handleMessage(final Message msg) {
+                switch (msg.what) {
+                case MSG_MOTION_EVENT:
+                    final int index = msg.arg1;
+                    final int action = replayData.mActions.get(index);
+                    final int x = replayData.mXCoords.get(index);
+                    final int y = replayData.mYCoords.get(index);
+                    final long origTime = replayData.mTimes.get(index);
+                    if (action == MotionEvent.ACTION_DOWN) {
+                        mOrigDownTime = origTime;
+                    }
+
+                    final MotionEvent me = MotionEvent.obtain(mOrigDownTime + timeAdjustment,
+                            origTime + timeAdjustment, action, x, y, 0);
+                    mainKeyboardView.processMotionEvent(me);
+                    me.recycle();
+                    break;
+                case MSG_DONE:
+                    mIsReplaying = false;
+                    break;
+                }
+            }
+        };
+
+        for (int i = 0; i < numActions; i++) {
+            final Message msg = Message.obtain(handler, MSG_MOTION_EVENT, i, 0);
+            final long msgTime = replayData.mTimes.get(i) + timeAdjustment;
+            handler.sendMessageAtTime(msg, msgTime);
+            if (DEBUG) {
+                Log.d(TAG, "queuing event at " + msgTime);
+            }
+        }
+        final long presentDoneTime = replayData.mTimes.get(numActions - 1) + timeAdjustment
+                + COMPLETION_TIME_MS;
+        handler.sendMessageAtTime(Message.obtain(handler, MSG_DONE), presentDoneTime);
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java
index dbf2d2982e4d09330f55291f0da8c6b987401ab3..925a72e45d12e33395c0295a705814826cb0b4f8 100644
--- a/java/src/com/android/inputmethod/research/ResearchLogger.java
+++ b/java/src/com/android/inputmethod/research/ResearchLogger.java
@@ -57,6 +57,7 @@ import android.widget.Toast;
 import com.android.inputmethod.keyboard.Key;
 import com.android.inputmethod.keyboard.Keyboard;
 import com.android.inputmethod.keyboard.KeyboardId;
+import com.android.inputmethod.keyboard.KeyboardSwitcher;
 import com.android.inputmethod.keyboard.KeyboardView;
 import com.android.inputmethod.keyboard.MainKeyboardView;
 import com.android.inputmethod.latin.Constants;
@@ -98,8 +99,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     private static final int OUTPUT_FORMAT_VERSION = 5;
     private static final String PREF_USABILITY_STUDY_MODE = "usability_study_mode";
     private static final String PREF_RESEARCH_HAS_SEEN_SPLASH = "pref_research_has_seen_splash";
-    /* package */ static final String FILENAME_PREFIX = "researchLog";
-    private static final String FILENAME_SUFFIX = ".txt";
+    /* package */ static final String LOG_FILENAME_PREFIX = "researchLog";
+    private static final String LOG_FILENAME_SUFFIX = ".txt";
+    /* package */ static final String USER_RECORDING_FILENAME_PREFIX = "recording";
+    private static final String USER_RECORDING_FILENAME_SUFFIX = ".txt";
     private static final SimpleDateFormat TIMESTAMP_DATEFORMAT =
             new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
     // Whether to show an indicator on the screen that logging is on.  Currently a very small red
@@ -129,9 +132,15 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     // the system to do so.
     // LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are
     // complete.
-    /* package */ ResearchLog mFeedbackLog;
     /* package */ MainLogBuffer mMainLogBuffer;
+    // TODO: Remove the feedback log.  The feedback log continuously captured user data in case the
+    // user wanted to submit it.  We now use the mUserRecordingLogBuffer to allow the user to
+    // explicitly reproduce a problem.
+    /* package */ ResearchLog mFeedbackLog;
     /* package */ LogBuffer mFeedbackLogBuffer;
+    /* package */ ResearchLog mUserRecordingLog;
+    /* package */ LogBuffer mUserRecordingLogBuffer;
+    private File mUserRecordingFile = null;
 
     private boolean mIsPasswordView = false;
     private boolean mIsLoggingSuspended = false;
@@ -155,6 +164,8 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     private MainKeyboardView mMainKeyboardView;
     private LatinIME mLatinIME;
     private final Statistics mStatistics;
+    private final MotionEventReader mMotionEventReader = new MotionEventReader();
+    private final Replayer mReplayer = new Replayer();
 
     private Intent mUploadIntent;
 
@@ -173,7 +184,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         return sInstance;
     }
 
-    public void init(final LatinIME latinIME) {
+    public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) {
         assert latinIME != null;
         if (latinIME == null) {
             Log.w(TAG, "IMS is null; logging is off");
@@ -210,6 +221,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         mLatinIME = latinIME;
         mPrefs = prefs;
         mUploadIntent = new Intent(mLatinIME, UploaderService.class);
+        mReplayer.setKeyboardSwitcher(keyboardSwitcher);
 
         if (ProductionFlag.IS_EXPERIMENTAL) {
             scheduleUploadingService(mLatinIME);
@@ -237,8 +249,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
 
     private void cleanupLoggingDir(final File dir, final long time) {
         for (File file : dir.listFiles()) {
-            if (file.getName().startsWith(ResearchLogger.FILENAME_PREFIX) &&
-                    file.lastModified() < time) {
+            final String filename = file.getName();
+            if ((filename.startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
+                    || filename.startsWith(ResearchLogger.USER_RECORDING_FILENAME_PREFIX))
+                    && file.lastModified() < time) {
                 file.delete();
             }
         }
@@ -335,9 +349,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
 
     private static int sLogFileCounter = 0;
 
-    private File createLogFile(File filesDir) {
+    private File createLogFile(final File filesDir) {
         final StringBuilder sb = new StringBuilder();
-        sb.append(FILENAME_PREFIX).append('-');
+        sb.append(LOG_FILENAME_PREFIX).append('-');
         sb.append(mUUIDString).append('-');
         sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-');
         // Sometimes logFiles are created within milliseconds of each other.  Append a counter to
@@ -349,7 +363,16 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             sLogFileCounter = 0;
         }
         sb.append(sLogFileCounter);
-        sb.append(FILENAME_SUFFIX);
+        sb.append(LOG_FILENAME_SUFFIX);
+        return new File(filesDir, sb.toString());
+    }
+
+    private File createUserRecordingFile(final File filesDir) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(USER_RECORDING_FILENAME_PREFIX).append('-');
+        sb.append(mUUIDString).append('-');
+        sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
+        sb.append(USER_RECORDING_FILENAME_SUFFIX);
         return new File(filesDir, sb.toString());
     }
 
@@ -517,37 +540,32 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         presentFeedbackDialog(latinIME);
     }
 
-    // TODO: currently unreachable.  Remove after being sure no menu is needed.
-    /*
-    public void presentResearchDialog(final LatinIME latinIME) {
-        final CharSequence title = latinIME.getString(R.string.english_ime_research_log);
-        final boolean showEnable = mIsLoggingSuspended || !sIsLogging;
-        final CharSequence[] items = new CharSequence[] {
-                latinIME.getString(R.string.research_feedback_menu_option),
-                showEnable ? latinIME.getString(R.string.research_enable_session_logging) :
-                        latinIME.getString(R.string.research_do_not_log_this_session)
-        };
-        final DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() {
-            @Override
-            public void onClick(DialogInterface di, int position) {
-                di.dismiss();
-                switch (position) {
-                    case 0:
-                        presentFeedbackDialog(latinIME);
-                        break;
-                    case 1:
-                        enableOrDisable(showEnable, latinIME);
-                        break;
-                }
-            }
+    private void cancelRecording() {
+        if (mUserRecordingLog != null) {
+            mUserRecordingLog.abort();
+        }
+        mUserRecordingLog = null;
+        mUserRecordingLogBuffer = null;
+    }
 
-        };
-        final AlertDialog.Builder builder = new AlertDialog.Builder(latinIME)
-                .setItems(items, listener)
-                .setTitle(title);
-        latinIME.showOptionDialog(builder.create());
+    private void startRecording() {
+        // Don't record the "start recording" motion.
+        commitCurrentLogUnit();
+        if (mUserRecordingLog != null) {
+            mUserRecordingLog.abort();
+        }
+        mUserRecordingFile = createUserRecordingFile(mFilesDir);
+        mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME);
+        mUserRecordingLogBuffer = new LogBuffer();
+    }
+
+    private void saveRecording() {
+        commitCurrentLogUnit();
+        publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true);
+        mUserRecordingLog.close(null);
+        mUserRecordingLog = null;
+        mUserRecordingLogBuffer = null;
     }
-    */
 
     private boolean mInFeedbackDialog = false;
 
@@ -631,38 +649,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         return null;
     }
 
-    static class LogStatement {
-        final String mName;
-
-        // mIsPotentiallyPrivate indicates that event contains potentially private information.  If
-        // the word that this event is a part of is determined to be privacy-sensitive, then this
-        // event should not be included in the output log.  The system waits to output until the
-        // containing word is known.
-        final boolean mIsPotentiallyPrivate;
-
-        // mIsPotentiallyRevealing indicates that this statement may disclose details about other
-        // words typed in other LogUnits.  This can happen if the user is not inserting spaces, and
-        // data from Suggestions and/or Composing text reveals the entire "megaword".  For example,
-        // say the user is typing "for the win", and the system wants to record the bigram "the
-        // win".  If the user types "forthe", omitting the space, the system will give "for the" as
-        // a suggestion.  If the user accepts the autocorrection, the suggestion for "for the" is
-        // included in the log for the word "the", disclosing that the previous word had been "for".
-        // For now, we simply do not include this data when logging part of a "megaword".
-        final boolean mIsPotentiallyRevealing;
-
-        // mKeys stores the names that are the attributes in the output json objects
-        final String[] mKeys;
-        private static final String[] NULL_KEYS = new String[0];
-
-        LogStatement(final String name, final boolean isPotentiallyPrivate,
-                final boolean isPotentiallyRevealing, final String... keys) {
-            mName = name;
-            mIsPotentiallyPrivate = isPotentiallyPrivate;
-            mIsPotentiallyRevealing = isPotentiallyRevealing;
-            mKeys = (keys == null) ? NULL_KEYS : keys;
-        }
-    }
-
     private static final LogStatement LOGSTATEMENT_FEEDBACK =
             new LogStatement("UserFeedback", false, false, "contents", "accountName");
     public void sendFeedback(final String feedbackContents, final boolean includeHistory,
@@ -770,7 +756,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
 
     private synchronized void enqueueEvent(final LogUnit logUnit, final LogStatement logStatement,
             final Object... values) {
-        assert values.length == logStatement.mKeys.length;
+        assert values.length == logStatement.getKeys().length;
         if (isAllowedToLog() && logUnit != null) {
             final long time = SystemClock.uptimeMillis();
             logUnit.addLogStatement(logStatement, time, values);
@@ -801,6 +787,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             if (mFeedbackLogBuffer != null) {
                 mFeedbackLogBuffer.shiftIn(mCurrentLogUnit);
             }
+            if (mUserRecordingLogBuffer != null) {
+                mUserRecordingLogBuffer.shiftIn(mCurrentLogUnit);
+            }
             mCurrentLogUnit = new LogUnit();
         } else {
             if (DEBUG) {
@@ -1058,7 +1047,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
      *
      */
     private static final LogStatement LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT =
-            new LogStatement("MotionEvent", true, false, "action", "MotionEvent");
+            new LogStatement("MotionEvent", true, false, "action", "MotionEvent", "loggingRelated");
     public static void mainKeyboardView_processMotionEvent(final MotionEvent me, final int action,
             final long eventTime, final int index, final int id, final int x, final int y) {
         if (me != null) {
@@ -1075,7 +1064,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             }
             final ResearchLogger researchLogger = getInstance();
             researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT,
-                    actionString, MotionEvent.obtain(me));
+                    actionString, MotionEvent.obtain(me), false);
             if (action == MotionEvent.ACTION_DOWN) {
                 // Subtract 1 from eventTime so the down event is included in the later
                 // LogUnit, not the earlier (the test is for inequality).
@@ -1442,13 +1431,21 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             final int code) {
         if (key != null) {
             String outputText = key.getOutputText();
-            getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT,
+            final ResearchLogger researchLogger = getInstance();
+            researchLogger.enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT,
                     Constants.printableCode(scrubDigitFromCodePoint(code)),
                     outputText == null ? null : scrubDigitsFromString(outputText.toString()),
                     x, y, ignoreModifierKey, altersCode, key.isEnabled());
+            if (code == Constants.CODE_RESEARCH) {
+                researchLogger.suppressResearchKeyMotionData();
+            }
         }
     }
 
+    private void suppressResearchKeyMotionData() {
+        mCurrentLogUnit.removeResearchButtonInvocation();
+    }
+
     /**
      * Log a call to PointerTracker.callListenerCallListenerOnRelease().
      *
diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java
index 5e3cf55e4153a1510de81827c8b5bf3de4a50440..69fb36d9c28895c03baf08fa880cd6d58fbf0a99 100644
--- a/java/src/com/android/inputmethod/research/UploaderService.java
+++ b/java/src/com/android/inputmethod/research/UploaderService.java
@@ -131,7 +131,7 @@ public final class UploaderService extends IntentService {
         final File[] files = mFilesDir.listFiles(new FileFilter() {
             @Override
             public boolean accept(File pathname) {
-                return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX)
+                return pathname.getName().startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
                         && !pathname.canWrite();
             }
         });