merge in jb-mr1-release history after reset to jb-mr1-dev
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index 07b3f31..35cbcf3 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -261,7 +261,8 @@
     <string name="research_feedback_dialog_title" translatable="false">Send feedback</string>
     <!-- Text for checkbox option to include user data in feedback for research purposes [CHAR LIMIT=50] -->
     <!-- TODO: remove translatable=false attribute once text is stable -->
-    <string name="research_feedback_include_history_label" translatable="false">Include last 5 words entered</string>
+    <!-- TODO: handle multilingual plurals -->
+    <string name="research_feedback_include_history_label" translatable="false">Include last <xliff:g id="word">%d</xliff:g> words entered</string>
     <!-- Hint to user about the text entry field where they should enter research feedback [CHAR LIMIT=40] -->
     <!-- TODO: remove translatable=false attribute once text is stable -->
     <string name="research_feedback_hint" translatable="false">Enter your feedback here.</string>
@@ -288,6 +289,10 @@
     <!-- TODO: remove translatable=false attribute once text is stable -->
     <string name="research_send_usage_info" translatable="false">Send usage info</string>
 
+    <!-- Name for the research uploading service to be displayed to users.  [CHAR LIMIT=50] -->
+    <!-- TODO: remove translatable=false attribute once text is stable -->
+    <string name="research_log_uploader_name" translatable="false">Research Uploader Service</string>
+
     <!-- Preference for input language selection -->
     <string name="select_language">Input languages</string>
 
diff --git a/java/src/com/android/inputmethod/keyboard/MainKeyboardView.java b/java/src/com/android/inputmethod/keyboard/MainKeyboardView.java
index 1b309a4..0cc0b63 100644
--- a/java/src/com/android/inputmethod/keyboard/MainKeyboardView.java
+++ b/java/src/com/android/inputmethod/keyboard/MainKeyboardView.java
@@ -110,7 +110,6 @@
             new WeakHashMap<Key, MoreKeysPanel>();
     private final boolean mConfigShowMoreKeysKeyboardAtTouchedPoint;
 
-    private final PointerTrackerParams mPointerTrackerParams;
     private final SuddenJumpingTouchEventHandler mTouchScreenRegulator;
 
     protected KeyDetector mKeyDetector;
@@ -127,11 +126,26 @@
         private static final int MSG_LONGPRESS_KEY = 2;
         private static final int MSG_DOUBLE_TAP = 3;
 
-        private final KeyTimerParams mParams;
+        private final int mKeyRepeatStartTimeout;
+        private final int mKeyRepeatInterval;
+        private final int mLongPressKeyTimeout;
+        private final int mLongPressShiftKeyTimeout;
+        private final int mIgnoreAltCodeKeyTimeout;
 
-        public KeyTimerHandler(MainKeyboardView outerInstance, KeyTimerParams params) {
+        public KeyTimerHandler(final MainKeyboardView outerInstance,
+                final TypedArray mainKeyboardViewAttr) {
             super(outerInstance);
-            mParams = params;
+
+            mKeyRepeatStartTimeout = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_keyRepeatStartTimeout, 0);
+            mKeyRepeatInterval = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_keyRepeatInterval, 0);
+            mLongPressKeyTimeout = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_longPressKeyTimeout, 0);
+            mLongPressShiftKeyTimeout = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_longPressShiftKeyTimeout, 0);
+            mIgnoreAltCodeKeyTimeout = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_ignoreAltCodeKeyTimeout, 0);
         }
 
         @Override
@@ -146,7 +160,7 @@
                 final Key currentKey = tracker.getKey();
                 if (currentKey != null && currentKey.mCode == msg.arg1) {
                     tracker.onRegisterKey(currentKey);
-                    startKeyRepeatTimer(tracker, mParams.mKeyRepeatInterval);
+                    startKeyRepeatTimer(tracker, mKeyRepeatInterval);
                 }
                 break;
             case MSG_LONGPRESS_KEY:
@@ -167,7 +181,7 @@
 
         @Override
         public void startKeyRepeatTimer(PointerTracker tracker) {
-            startKeyRepeatTimer(tracker, mParams.mKeyRepeatStartTimeout);
+            startKeyRepeatTimer(tracker, mKeyRepeatStartTimeout);
         }
 
         public void cancelKeyRepeatTimer() {
@@ -185,7 +199,7 @@
             final int delay;
             switch (code) {
             case Keyboard.CODE_SHIFT:
-                delay = mParams.mLongPressShiftKeyTimeout;
+                delay = mLongPressShiftKeyTimeout;
                 break;
             default:
                 delay = 0;
@@ -206,15 +220,15 @@
             final int delay;
             switch (key.mCode) {
             case Keyboard.CODE_SHIFT:
-                delay = mParams.mLongPressShiftKeyTimeout;
+                delay = mLongPressShiftKeyTimeout;
                 break;
             default:
                 if (KeyboardSwitcher.getInstance().isInMomentarySwitchState()) {
                     // We use longer timeout for sliding finger input started from the symbols
                     // mode key.
-                    delay = mParams.mLongPressKeyTimeout * 3;
+                    delay = mLongPressKeyTimeout * 3;
                 } else {
-                    delay = mParams.mLongPressKeyTimeout;
+                    delay = mLongPressKeyTimeout;
                 }
                 break;
             }
@@ -268,7 +282,7 @@
             }
 
             sendMessageDelayed(
-                    obtainMessage(MSG_TYPING_STATE_EXPIRED), mParams.mIgnoreAltCodeKeyTimeout);
+                    obtainMessage(MSG_TYPING_STATE_EXPIRED), mIgnoreAltCodeKeyTimeout);
             if (isTyping) {
                 return;
             }
@@ -307,50 +321,6 @@
         }
     }
 
-    public static class PointerTrackerParams {
-        public final boolean mSlidingKeyInputEnabled;
-        public final int mTouchNoiseThresholdTime;
-        public final float mTouchNoiseThresholdDistance;
-
-        public static final PointerTrackerParams DEFAULT = new PointerTrackerParams();
-
-        private PointerTrackerParams() {
-            mSlidingKeyInputEnabled = false;
-            mTouchNoiseThresholdTime =0;
-            mTouchNoiseThresholdDistance = 0;
-        }
-
-        public PointerTrackerParams(TypedArray mainKeyboardViewAttr) {
-            mSlidingKeyInputEnabled = mainKeyboardViewAttr.getBoolean(
-                    R.styleable.MainKeyboardView_slidingKeyInputEnable, false);
-            mTouchNoiseThresholdTime = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_touchNoiseThresholdTime, 0);
-            mTouchNoiseThresholdDistance = mainKeyboardViewAttr.getDimension(
-                    R.styleable.MainKeyboardView_touchNoiseThresholdDistance, 0);
-        }
-    }
-
-    static class KeyTimerParams {
-        public final int mKeyRepeatStartTimeout;
-        public final int mKeyRepeatInterval;
-        public final int mLongPressKeyTimeout;
-        public final int mLongPressShiftKeyTimeout;
-        public final int mIgnoreAltCodeKeyTimeout;
-
-        public KeyTimerParams(TypedArray mainKeyboardViewAttr) {
-            mKeyRepeatStartTimeout = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_keyRepeatStartTimeout, 0);
-            mKeyRepeatInterval = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_keyRepeatInterval, 0);
-            mLongPressKeyTimeout = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_longPressKeyTimeout, 0);
-            mLongPressShiftKeyTimeout = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_longPressShiftKeyTimeout, 0);
-            mIgnoreAltCodeKeyTimeout = mainKeyboardViewAttr.getInt(
-                    R.styleable.MainKeyboardView_ignoreAltCodeKeyTimeout, 0);
-        }
-    }
-
     public MainKeyboardView(Context context, AttributeSet attrs) {
         this(context, attrs, R.attr.mainKeyboardViewStyle);
     }
@@ -389,19 +359,15 @@
         final int altCodeKeyWhileTypingFadeinAnimatorResId = a.getResourceId(
                 R.styleable.MainKeyboardView_altCodeKeyWhileTypingFadeinAnimator, 0);
 
-        final KeyTimerParams keyTimerParams = new KeyTimerParams(a);
-        mPointerTrackerParams = new PointerTrackerParams(a);
-
         final float keyHysteresisDistance = a.getDimension(
                 R.styleable.MainKeyboardView_keyHysteresisDistance, 0);
         mKeyDetector = new KeyDetector(keyHysteresisDistance);
-        mKeyTimerHandler = new KeyTimerHandler(this, keyTimerParams);
+        mKeyTimerHandler = new KeyTimerHandler(this, a);
         mConfigShowMoreKeysKeyboardAtTouchedPoint = a.getBoolean(
                 R.styleable.MainKeyboardView_showMoreKeysKeyboardAtTouchedPoint, false);
+        PointerTracker.setParameters(a);
         a.recycle();
 
-        PointerTracker.setParameters(mPointerTrackerParams);
-
         mLanguageOnSpacebarFadeoutAnimator = loadObjectAnimator(
                 languageOnSpacebarFadeoutAnimatorResId, this);
         mAltCodeKeyWhileTypingFadeoutAnimator = loadObjectAnimator(
diff --git a/java/src/com/android/inputmethod/keyboard/PointerTracker.java b/java/src/com/android/inputmethod/keyboard/PointerTracker.java
index d9a7cb4..67f37a7 100644
--- a/java/src/com/android/inputmethod/keyboard/PointerTracker.java
+++ b/java/src/com/android/inputmethod/keyboard/PointerTracker.java
@@ -16,6 +16,7 @@
 
 package com.android.inputmethod.keyboard;
 
+import android.content.res.TypedArray;
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.os.SystemClock;
@@ -28,6 +29,7 @@
 import com.android.inputmethod.latin.CollectionUtils;
 import com.android.inputmethod.latin.InputPointers;
 import com.android.inputmethod.latin.LatinImeLogger;
+import com.android.inputmethod.latin.R;
 import com.android.inputmethod.latin.define.ProductionFlag;
 import com.android.inputmethod.research.ResearchLogger;
 
@@ -118,9 +120,36 @@
         }
     }
 
+    static class PointerTrackerParams {
+        public final boolean mSlidingKeyInputEnabled;
+        public final int mTouchNoiseThresholdTime;
+        public final float mTouchNoiseThresholdDistance;
+        public final int mTouchNoiseThresholdDistanceSquared;
+
+        public static final PointerTrackerParams DEFAULT = new PointerTrackerParams();
+
+        private PointerTrackerParams() {
+            mSlidingKeyInputEnabled = false;
+            mTouchNoiseThresholdTime = 0;
+            mTouchNoiseThresholdDistance = 0.0f;
+            mTouchNoiseThresholdDistanceSquared = 0;
+        }
+
+        public PointerTrackerParams(TypedArray mainKeyboardViewAttr) {
+            mSlidingKeyInputEnabled = mainKeyboardViewAttr.getBoolean(
+                    R.styleable.MainKeyboardView_slidingKeyInputEnable, false);
+            mTouchNoiseThresholdTime = mainKeyboardViewAttr.getInt(
+                    R.styleable.MainKeyboardView_touchNoiseThresholdTime, 0);
+            final float touchNouseThresholdDistance = mainKeyboardViewAttr.getDimension(
+                    R.styleable.MainKeyboardView_touchNoiseThresholdDistance, 0);
+            mTouchNoiseThresholdDistance = touchNouseThresholdDistance;
+            mTouchNoiseThresholdDistanceSquared =
+                    (int)(touchNouseThresholdDistance * touchNouseThresholdDistance);
+        }
+    }
+
     // Parameters for pointer handling.
-    private static MainKeyboardView.PointerTrackerParams sParams;
-    private static int sTouchNoiseThresholdDistanceSquared;
+    private static PointerTrackerParams sParams;
     private static boolean sNeedsPhantomSuddenMoveEventHack;
 
     private static final ArrayList<PointerTracker> sTrackers = CollectionUtils.newArrayList();
@@ -192,14 +221,11 @@
             sPointerTrackerQueue = null;
         }
         sNeedsPhantomSuddenMoveEventHack = needsPhantomSuddenMoveEventHack;
-
-        setParameters(MainKeyboardView.PointerTrackerParams.DEFAULT);
+        sParams = PointerTrackerParams.DEFAULT;
     }
 
-    public static void setParameters(MainKeyboardView.PointerTrackerParams params) {
-        sParams = params;
-        sTouchNoiseThresholdDistanceSquared = (int)(
-                params.mTouchNoiseThresholdDistance * params.mTouchNoiseThresholdDistance);
+    public static void setParameters(final TypedArray mainKeyboardViewAttr) {
+        sParams = new PointerTrackerParams(mainKeyboardViewAttr);
     }
 
     private static void updateGestureHandlingMode() {
@@ -210,17 +236,17 @@
     }
 
     // Note that this method is called from a non-UI thread.
-    public static void setMainDictionaryAvailability(boolean mainDictionaryAvailable) {
+    public static void setMainDictionaryAvailability(final boolean mainDictionaryAvailable) {
         sMainDictionaryAvailable = mainDictionaryAvailable;
         updateGestureHandlingMode();
     }
 
-    public static void setGestureHandlingEnabledByUser(boolean gestureHandlingEnabledByUser) {
+    public static void setGestureHandlingEnabledByUser(final boolean gestureHandlingEnabledByUser) {
         sGestureHandlingEnabledByUser = gestureHandlingEnabledByUser;
         updateGestureHandlingMode();
     }
 
-    public static PointerTracker getPointerTracker(final int id, KeyEventHandler handler) {
+    public static PointerTracker getPointerTracker(final int id, final KeyEventHandler handler) {
         final ArrayList<PointerTracker> trackers = sTrackers;
 
         // Create pointer trackers until we can get 'id+1'-th tracker, if needed.
@@ -236,7 +262,7 @@
         return sPointerTrackerQueue != null ? sPointerTrackerQueue.isAnyInSlidingKeyInput() : false;
     }
 
-    public static void setKeyboardActionListener(KeyboardActionListener listener) {
+    public static void setKeyboardActionListener(final KeyboardActionListener listener) {
         final int trackersSize = sTrackers.size();
         for (int i = 0; i < trackersSize; ++i) {
             final PointerTracker tracker = sTrackers.get(i);
@@ -244,7 +270,7 @@
         }
     }
 
-    public static void setKeyDetector(KeyDetector keyDetector) {
+    public static void setKeyDetector(final KeyDetector keyDetector) {
         final int trackersSize = sTrackers.size();
         for (int i = 0; i < trackersSize; ++i) {
             final PointerTracker tracker = sTrackers.get(i);
@@ -298,9 +324,10 @@
         sAggregratedPointers.reset();
     }
 
-    private PointerTracker(int id, KeyEventHandler handler) {
-        if (handler == null)
+    private PointerTracker(final int id, final KeyEventHandler handler) {
+        if (handler == null) {
             throw new NullPointerException();
+        }
         mPointerId = id;
         mGestureStroke = new GestureStroke(id);
         setKeyDetectorInner(handler.getKeyDetector());
@@ -310,7 +337,7 @@
     }
 
     // Returns true if keyboard has been changed by this callback.
-    private boolean callListenerOnPressAndCheckKeyboardLayoutChange(Key key) {
+    private boolean callListenerOnPressAndCheckKeyboardLayoutChange(final Key key) {
         if (mInGesture) {
             return false;
         }
@@ -335,7 +362,8 @@
 
     // Note that we need primaryCode argument because the keyboard may in shifted state and the
     // primaryCode is different from {@link Key#mCode}.
-    private void callListenerOnCodeInput(Key key, int primaryCode, int x, int y) {
+    private void callListenerOnCodeInput(final Key key, final int primaryCode, final int x,
+            final int y) {
         final boolean ignoreModifierKey = mIgnoreModifierKey && key.isModifier();
         final boolean altersCode = key.altCodeWhileTyping() && mTimerProxy.isTypingState();
         final int code = altersCode ? key.mAltCode : primaryCode;
@@ -364,7 +392,8 @@
 
     // Note that we need primaryCode argument because the keyboard may in shifted state and the
     // primaryCode is different from {@link Key#mCode}.
-    private void callListenerOnRelease(Key key, int primaryCode, boolean withSliding) {
+    private void callListenerOnRelease(final Key key, final int primaryCode,
+            final boolean withSliding) {
         if (mInGesture) {
             return;
         }
@@ -387,15 +416,16 @@
     }
 
     private void callListenerOnCancelInput() {
-        if (DEBUG_LISTENER)
+        if (DEBUG_LISTENER) {
             Log.d(TAG, "onCancelInput");
+        }
         if (ProductionFlag.IS_EXPERIMENTAL) {
             ResearchLogger.pointerTracker_callListenerOnCancelInput();
         }
         mListener.onCancelInput();
     }
 
-    private void setKeyDetectorInner(KeyDetector keyDetector) {
+    private void setKeyDetectorInner(final KeyDetector keyDetector) {
         mKeyDetector = keyDetector;
         mKeyboard = keyDetector.getKeyboard();
         mIsAlphabetKeyboard = mKeyboard.mId.isAlphabetKeyboard();
@@ -425,11 +455,11 @@
         return mCurrentKey != null && mCurrentKey.isModifier();
     }
 
-    public Key getKeyOn(int x, int y) {
+    public Key getKeyOn(final int x, final int y) {
         return mKeyDetector.detectHitKey(x, y);
     }
 
-    private void setReleasedKeyGraphics(Key key) {
+    private void setReleasedKeyGraphics(final Key key) {
         mDrawingProxy.dismissKeyPreview(this);
         if (key == null) {
             return;
@@ -460,7 +490,7 @@
         }
     }
 
-    private void setPressedKeyGraphics(Key key) {
+    private void setPressedKeyGraphics(final Key key) {
         if (key == null) {
             return;
         }
@@ -499,17 +529,17 @@
         }
     }
 
-    private void updateReleaseKeyGraphics(Key key) {
+    private void updateReleaseKeyGraphics(final Key key) {
         key.onReleased();
         mDrawingProxy.invalidateKey(key);
     }
 
-    private void updatePressKeyGraphics(Key key) {
+    private void updatePressKeyGraphics(final Key key) {
         key.onPressed();
         mDrawingProxy.invalidateKey(key);
     }
 
-    public void drawGestureTrail(Canvas canvas, Paint paint) {
+    public void drawGestureTrail(final Canvas canvas, final Paint paint) {
         if (mInGesture) {
             mGestureStroke.drawGestureTrail(canvas, paint);
         }
@@ -527,22 +557,22 @@
         return mDownTime;
     }
 
-    private Key onDownKey(int x, int y, long eventTime) {
+    private Key onDownKey(final int x, final int y, final long eventTime) {
         mDownTime = eventTime;
         return onMoveToNewKey(onMoveKeyInternal(x, y), x, y);
     }
 
-    private Key onMoveKeyInternal(int x, int y) {
+    private Key onMoveKeyInternal(final int x, final int y) {
         mLastX = x;
         mLastY = y;
         return mKeyDetector.detectHitKey(x, y);
     }
 
-    private Key onMoveKey(int x, int y) {
+    private Key onMoveKey(final int x, final int y) {
         return onMoveKeyInternal(x, y);
     }
 
-    private Key onMoveToNewKey(Key newKey, int x, int y) {
+    private Key onMoveToNewKey(final Key newKey, final int x, final int y) {
         mCurrentKey = newKey;
         mKeyX = x;
         mKeyY = y;
@@ -557,14 +587,14 @@
         mListener.onStartBatchInput();
     }
 
-    private void updateBatchInput(InputPointers batchPoints) {
+    private void updateBatchInput(final InputPointers batchPoints) {
         if (DEBUG_LISTENER) {
             Log.d(TAG, "onUpdateBatchInput: batchPoints=" + batchPoints.getPointerSize());
         }
         mListener.onUpdateBatchInput(batchPoints);
     }
 
-    private void endBatchInput(InputPointers batchPoints) {
+    private void endBatchInput(final InputPointers batchPoints) {
         if (DEBUG_LISTENER) {
             Log.d(TAG, "onEndBatchInput: batchPoints=" + batchPoints.getPointerSize());
         }
@@ -585,7 +615,7 @@
         mLastRecognitionTime = 0;
     }
 
-    private boolean updateBatchInputRecognitionState(long eventTime, int size) {
+    private boolean updateBatchInputRecognitionState(final long eventTime, final int size) {
         if (size > mLastRecognitionPointSize
                 && eventTime > mLastRecognitionTime + MIN_GESTURE_RECOGNITION_TIME) {
             mLastRecognitionPointSize = size;
@@ -595,8 +625,8 @@
         return false;
     }
 
-    public void processMotionEvent(int action, int x, int y, long eventTime,
-            KeyEventHandler handler) {
+    public void processMotionEvent(final int action, final int x, final int y, final long eventTime,
+            final KeyEventHandler handler) {
         switch (action) {
         case MotionEvent.ACTION_DOWN:
         case MotionEvent.ACTION_POINTER_DOWN:
@@ -615,9 +645,11 @@
         }
     }
 
-    public void onDownEvent(int x, int y, long eventTime, KeyEventHandler handler) {
-        if (DEBUG_EVENT)
+    public void onDownEvent(final int x, final int y, final long eventTime,
+            final KeyEventHandler handler) {
+        if (DEBUG_EVENT) {
             printTouchEvent("onDownEvent:", x, y, eventTime);
+        }
 
         mDrawingProxy = handler.getDrawingProxy();
         mTimerProxy = handler.getTimerProxy();
@@ -629,7 +661,7 @@
             final int dx = x - mLastX;
             final int dy = y - mLastY;
             final int distanceSquared = (dx * dx + dy * dy);
-            if (distanceSquared < sTouchNoiseThresholdDistanceSquared) {
+            if (distanceSquared < sParams.mTouchNoiseThresholdDistanceSquared) {
                 if (DEBUG_MODE)
                     Log.w(TAG, "onDownEvent: ignore potential noise: time=" + deltaT
                             + " distance=" + distanceSquared);
@@ -665,7 +697,7 @@
         }
     }
 
-    private void onDownEventInternal(int x, int y, long eventTime) {
+    private void onDownEventInternal(final int x, final int y, final long eventTime) {
         Key key = onDownKey(x, y, eventTime);
         // Sliding key is allowed when 1) enabled by configuration, 2) this pointer starts sliding
         // from modifier key, or 3) this pointer's KeyDetector always allows sliding input.
@@ -690,15 +722,15 @@
         }
     }
 
-    private void startSlidingKeyInput(Key key) {
+    private void startSlidingKeyInput(final Key key) {
         if (!mIsInSlidingKeyInput) {
             mIgnoreModifierKey = key.isModifier();
         }
         mIsInSlidingKeyInput = true;
     }
 
-    private void onGestureMoveEvent(PointerTracker tracker, int x, int y, long eventTime,
-            boolean isHistorical, Key key) {
+    private void onGestureMoveEvent(final PointerTracker tracker, final int x, final int y,
+            final long eventTime, final boolean isHistorical, final Key key) {
         final int gestureTime = (int)(eventTime - tracker.getDownTime());
         if (sShouldHandleGesture && mIsPossibleGesture) {
             final GestureStroke stroke = mGestureStroke;
@@ -717,11 +749,13 @@
         }
     }
 
-    public void onMoveEvent(int x, int y, long eventTime, MotionEvent me) {
-        if (DEBUG_MOVE_EVENT)
+    public void onMoveEvent(final int x, final int y, final long eventTime, final MotionEvent me) {
+        if (DEBUG_MOVE_EVENT) {
             printTouchEvent("onMoveEvent:", x, y, eventTime);
-        if (mKeyAlreadyProcessed)
+        }
+        if (mKeyAlreadyProcessed) {
             return;
+        }
 
         if (me != null) {
             // Add historical points to gesture path.
@@ -811,8 +845,9 @@
                         // touch panels when there are close multiple touches.
                         // Caveat: When in chording input mode with a modifier key, we don't use
                         // this hack.
-                        if (me != null && me.getPointerCount() > 1
-                                && !sPointerTrackerQueue.hasModifierKeyOlderThan(this)) {
+                        final PointerTrackerQueue queue = sPointerTrackerQueue;
+                        if (queue != null && queue.size() > 1
+                                && !queue.hasModifierKeyOlderThan(this)) {
                             onUpEventInternal();
                         }
                         if (!mIsPossibleGesture) {
@@ -841,9 +876,10 @@
         }
     }
 
-    public void onUpEvent(int x, int y, long eventTime) {
-        if (DEBUG_EVENT)
+    public void onUpEvent(final int x, final int y, final long eventTime) {
+        if (DEBUG_EVENT) {
             printTouchEvent("onUpEvent  :", x, y, eventTime);
+        }
 
         final PointerTrackerQueue queue = sPointerTrackerQueue;
         if (queue != null) {
@@ -865,9 +901,10 @@
     // This pointer tracker needs to keep the key top graphics "pressed", but needs to get a
     // "virtual" up event.
     @Override
-    public void onPhantomUpEvent(long eventTime) {
-        if (DEBUG_EVENT)
+    public void onPhantomUpEvent(final long eventTime) {
+        if (DEBUG_EVENT) {
             printTouchEvent("onPhntEvent:", getLastX(), getLastY(), eventTime);
+        }
         onUpEventInternal();
         mKeyAlreadyProcessed = true;
     }
@@ -898,14 +935,15 @@
         // This event will be recognized as a regular code input. Clear unused batch points so they
         // are not mistakenly included in the next batch event.
         clearBatchInputPointsOfAllPointerTrackers();
-        if (mKeyAlreadyProcessed)
+        if (mKeyAlreadyProcessed) {
             return;
+        }
         if (mCurrentKey != null && !mCurrentKey.isRepeatable()) {
             detectAndSendKey(mCurrentKey, mKeyX, mKeyY);
         }
     }
 
-    public void onShowMoreKeysPanel(int x, int y, KeyEventHandler handler) {
+    public void onShowMoreKeysPanel(final int x, final int y, final KeyEventHandler handler) {
         abortBatchInput();
         onLongPressed();
         mIsShowingMoreKeysPanel = true;
@@ -921,9 +959,10 @@
         }
     }
 
-    public void onCancelEvent(int x, int y, long eventTime) {
-        if (DEBUG_EVENT)
+    public void onCancelEvent(final int x, final int y, final long eventTime) {
+        if (DEBUG_EVENT) {
             printTouchEvent("onCancelEvt:", x, y, eventTime);
+        }
 
         final PointerTrackerQueue queue = sPointerTrackerQueue;
         if (queue != null) {
@@ -943,24 +982,25 @@
         }
     }
 
-    private void startRepeatKey(Key key) {
+    private void startRepeatKey(final Key key) {
         if (key != null && key.isRepeatable() && !mInGesture) {
             onRegisterKey(key);
             mTimerProxy.startKeyRepeatTimer(this);
         }
     }
 
-    public void onRegisterKey(Key key) {
+    public void onRegisterKey(final Key key) {
         if (key != null) {
             detectAndSendKey(key, key.mX, key.mY);
             mTimerProxy.startTypingStateTimer(key);
         }
     }
 
-    private boolean isMajorEnoughMoveToBeOnNewKey(int x, int y, Key newKey) {
-        if (mKeyDetector == null)
+    private boolean isMajorEnoughMoveToBeOnNewKey(final int x, final int y, final Key newKey) {
+        if (mKeyDetector == null) {
             throw new NullPointerException("keyboard and/or key detector not set");
-        Key curKey = mCurrentKey;
+        }
+        final Key curKey = mCurrentKey;
         if (newKey == curKey) {
             return false;
         } else if (curKey != null) {
@@ -971,24 +1011,25 @@
         }
     }
 
-    private void startLongPressTimer(Key key) {
+    private void startLongPressTimer(final Key key) {
         if (key != null && key.isLongPressEnabled() && !mInGesture) {
             mTimerProxy.startLongPressTimer(this);
         }
     }
 
-    private void detectAndSendKey(Key key, int x, int y) {
+    private void detectAndSendKey(final Key key, final int x, final int y) {
         if (key == null) {
             callListenerOnCancelInput();
             return;
         }
 
-        int code = key.mCode;
+        final int code = key.mCode;
         callListenerOnCodeInput(key, code, x, y);
         callListenerOnRelease(key, code, false);
     }
 
-    private void printTouchEvent(String title, int x, int y, long eventTime) {
+    private void printTouchEvent(final String title, final int x, final int y,
+            final long eventTime) {
         final Key key = mKeyDetector.detectHitKey(x, y);
         final String code = KeyDetector.printableCode(key);
         Log.d(TAG, String.format("%s%s[%d] %4d %4d %5d %s", title,
diff --git a/java/src/com/android/inputmethod/latin/InputPointers.java b/java/src/com/android/inputmethod/latin/InputPointers.java
index cbc916a..ff2feb5 100644
--- a/java/src/com/android/inputmethod/latin/InputPointers.java
+++ b/java/src/com/android/inputmethod/latin/InputPointers.java
@@ -93,7 +93,7 @@
         }
         mXCoordinates.append(xCoordinates, startPos, length);
         mYCoordinates.append(yCoordinates, startPos, length);
-        mPointerIds.fill(pointerId, startPos, length);
+        mPointerIds.fill(pointerId, mPointerIds.getLength(), length);
         mTimes.append(times, startPos, length);
     }
 
@@ -124,4 +124,10 @@
     public int[] getTimes() {
         return mTimes.getPrimitiveArray();
     }
+
+    @Override
+    public String toString() {
+        return "size=" + getPointerSize() + " id=" + mPointerIds + " time=" + mTimes
+                + " x=" + mXCoordinates + " y=" + mYCoordinates;
+    }
 }
diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index 4d5f93b..df200cd 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -1261,11 +1261,6 @@
         }
         mLastKeyTime = when;
         mConnection.beginBatchEdit();
-
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_onCodeInput(primaryCode, x, y);
-        }
-
         final KeyboardSwitcher switcher = mKeyboardSwitcher;
         // The space state depends only on the last character pressed and its own previous
         // state. Here, we revert the space state to neutral if the key is actually modifying
@@ -1347,6 +1342,9 @@
             mLastComposedWord.deactivate();
         mEnteredText = null;
         mConnection.endBatchEdit();
+        if (ProductionFlag.IS_EXPERIMENTAL) {
+            ResearchLogger.latinIME_onCodeInput(primaryCode, x, y);
+        }
     }
 
     // Called from PointerTracker through the KeyboardActionListener interface
diff --git a/java/src/com/android/inputmethod/latin/ResizableIntArray.java b/java/src/com/android/inputmethod/latin/ResizableIntArray.java
index 387d45a..c660f92 100644
--- a/java/src/com/android/inputmethod/latin/ResizableIntArray.java
+++ b/java/src/com/android/inputmethod/latin/ResizableIntArray.java
@@ -131,4 +131,16 @@
             mLength = endPos;
         }
     }
+
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < mLength; i++) {
+            if (i != 0) {
+                sb.append(",");
+            }
+            sb.append(mArray[i]);
+        }
+        return "[" + sb + "]";
+    }
 }
diff --git a/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java
new file mode 100644
index 0000000..5124a35
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java
@@ -0,0 +1,33 @@
+/*
+ * 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.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Arrange for the uploading service to be run on regular intervals.
+ */
+public final class BootBroadcastReceiver extends BroadcastReceiver {
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+            ResearchLogger.scheduleUploadingService(context);
+        }
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/FeedbackActivity.java b/java/src/com/android/inputmethod/research/FeedbackActivity.java
index c9f3b47..11eae88 100644
--- a/java/src/com/android/inputmethod/research/FeedbackActivity.java
+++ b/java/src/com/android/inputmethod/research/FeedbackActivity.java
@@ -18,10 +18,7 @@
 
 import android.app.Activity;
 import android.os.Bundle;
-import android.text.Editable;
-import android.view.View;
 import android.widget.CheckBox;
-import android.widget.EditText;
 
 import com.android.inputmethod.latin.R;
 
@@ -31,6 +28,11 @@
         super.onCreate(savedInstanceState);
         setContentView(R.layout.research_feedback_activity);
         final FeedbackLayout layout = (FeedbackLayout) findViewById(R.id.research_feedback_layout);
+        final CheckBox checkbox = (CheckBox) findViewById(R.id.research_feedback_include_history);
+        final CharSequence cs = checkbox.getText();
+        final String actualString = String.format(cs.toString(),
+                ResearchLogger.FEEDBACK_WORD_BUFFER_SIZE);
+        checkbox.setText(actualString);
         layout.setActivity(this);
     }
 
diff --git a/java/src/com/android/inputmethod/research/LogBuffer.java b/java/src/com/android/inputmethod/research/LogBuffer.java
new file mode 100644
index 0000000..ae7b157
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogBuffer.java
@@ -0,0 +1,113 @@
+/*
+ * 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 com.android.inputmethod.latin.CollectionUtils;
+
+import java.util.LinkedList;
+
+/**
+ * A buffer that holds a fixed number of LogUnits.
+ *
+ * LogUnits are added in and shifted out in temporal order.  Only a subset of the LogUnits are
+ * actual words; the other LogUnits do not count toward the word limit.  Once the buffer reaches
+ * capacity, adding another LogUnit that is a word evicts the oldest LogUnits out one at a time to
+ * stay under the capacity limit.
+ */
+public class LogBuffer {
+    protected final LinkedList<LogUnit> mLogUnits;
+    /* package for test */ int mWordCapacity;
+    // The number of members of mLogUnits that are actual words.
+    protected int mNumActualWords;
+
+    /**
+     * Create a new LogBuffer that can hold a fixed number of LogUnits that are words (and
+     * unlimited number of non-word LogUnits), and that outputs its result to a researchLog.
+     *
+     * @param wordCapacity maximum number of words
+     */
+    LogBuffer(final int wordCapacity) {
+        if (wordCapacity <= 0) {
+            throw new IllegalArgumentException("wordCapacity must be 1 or greater.");
+        }
+        mLogUnits = CollectionUtils.newLinkedList();
+        mWordCapacity = wordCapacity;
+        mNumActualWords = 0;
+    }
+
+    /**
+     * Adds a new LogUnit to the front of the LIFO queue, evicting existing LogUnit's
+     * (oldest first) if word capacity is reached.
+     */
+    public void shiftIn(LogUnit newLogUnit) {
+        if (newLogUnit.getWord() == null) {
+            // This LogUnit isn't a word, so it doesn't count toward the word-limit.
+            mLogUnits.add(newLogUnit);
+            return;
+        }
+        if (mNumActualWords == mWordCapacity) {
+            shiftOutThroughFirstWord();
+        }
+        mLogUnits.add(newLogUnit);
+        mNumActualWords++; // Must be a word, or we wouldn't be here.
+    }
+
+    private void shiftOutThroughFirstWord() {
+        while (!mLogUnits.isEmpty()) {
+            final LogUnit logUnit = mLogUnits.removeFirst();
+            onShiftOut(logUnit);
+            if (logUnit.hasWord()) {
+                // Successfully shifted out a word-containing LogUnit and made space for the new
+                // LogUnit.
+                mNumActualWords--;
+                break;
+            }
+        }
+    }
+
+    /**
+     * Removes all LogUnits from the buffer without calling onShiftOut().
+     */
+    public void clear() {
+        mLogUnits.clear();
+        mNumActualWords = 0;
+    }
+
+    /**
+     * Called when a LogUnit is removed from the LogBuffer as a result of a shiftIn.  LogUnits are
+     * removed in the order entered.  This method is not called when shiftOut is called directly.
+     *
+     * Base class does nothing; subclasses may override.
+     */
+    protected void onShiftOut(LogUnit logUnit) {
+    }
+
+    /**
+     * Called to deliberately remove the oldest LogUnit.  Usually called when draining the
+     * LogBuffer.
+     */
+    public LogUnit shiftOut() {
+        if (mLogUnits.isEmpty()) {
+            return null;
+        }
+        final LogUnit logUnit = mLogUnits.removeFirst();
+        if (logUnit.hasWord()) {
+            mNumActualWords--;
+        }
+        return logUnit;
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java
new file mode 100644
index 0000000..d8b3a29
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogUnit.java
@@ -0,0 +1,83 @@
+/*
+ * 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 com.android.inputmethod.latin.CollectionUtils;
+
+import java.util.ArrayList;
+
+/**
+ * A group of log statements related to each other.
+ *
+ * A LogUnit is collection of LogStatements, each of which is generated by at a particular point
+ * in the code.  (There is no LogStatement class; the data is stored across the instance variables
+ * here.)  A single LogUnit's statements can correspond to all the calls made while in the same
+ * composing region, or all the calls between committing the last composing region, and the first
+ * character of the next composing region.
+ *
+ * Individual statements in a log may be marked as potentially private.  If so, then they are only
+ * published to a ResearchLog if the ResearchLogger determines that publishing the entire LogUnit
+ * will not violate the user's privacy.  Checks for this may include whether other LogUnits have
+ * been published recently, or whether the LogUnit contains numbers, etc.
+ */
+/* package */ class LogUnit {
+    private final ArrayList<String[]> mKeysList = CollectionUtils.newArrayList();
+    private final ArrayList<Object[]> mValuesList = CollectionUtils.newArrayList();
+    private final ArrayList<Boolean> mIsPotentiallyPrivate = CollectionUtils.newArrayList();
+    private String mWord;
+    private boolean mContainsDigit;
+
+    public void addLogStatement(final String[] keys, final Object[] values,
+            final Boolean isPotentiallyPrivate) {
+        mKeysList.add(keys);
+        mValuesList.add(values);
+        mIsPotentiallyPrivate.add(isPotentiallyPrivate);
+    }
+
+    public void publishTo(final ResearchLog researchLog, final boolean isIncludingPrivateData) {
+        final int size = mKeysList.size();
+        for (int i = 0; i < size; i++) {
+            if (!mIsPotentiallyPrivate.get(i) || isIncludingPrivateData) {
+                researchLog.outputEvent(mKeysList.get(i), mValuesList.get(i));
+            }
+        }
+    }
+
+    public void setWord(String word) {
+        mWord = word;
+    }
+
+    public String getWord() {
+        return mWord;
+    }
+
+    public boolean hasWord() {
+        return mWord != null;
+    }
+
+    public void setContainsDigit() {
+        mContainsDigit = true;
+    }
+
+    public boolean hasDigit() {
+        return mContainsDigit;
+    }
+
+    public boolean isEmpty() {
+        return mKeysList.isEmpty();
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/MainLogBuffer.java b/java/src/com/android/inputmethod/research/MainLogBuffer.java
new file mode 100644
index 0000000..745768d
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/MainLogBuffer.java
@@ -0,0 +1,127 @@
+/*
+ * 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 com.android.inputmethod.latin.Dictionary;
+import com.android.inputmethod.latin.Suggest;
+
+import java.util.Random;
+
+public class MainLogBuffer extends LogBuffer {
+    // The size of the n-grams logged.  E.g. N_GRAM_SIZE = 2 means to sample bigrams.
+    private static final int N_GRAM_SIZE = 2;
+    // The number of words between n-grams to omit from the log.
+    private static final int DEFAULT_NUMBER_OF_WORDS_BETWEEN_SAMPLES = 18;
+
+    private final ResearchLog mResearchLog;
+    private Suggest mSuggest;
+
+    // The minimum periodicity with which n-grams can be sampled.  E.g. mWinWordPeriod is 10 if
+    // every 10th bigram is sampled, i.e., words 1-8 are not, but the bigram at words 9 and 10, etc.
+    // for 11-18, and the bigram at words 19 and 20.  If an n-gram is not safe (e.g. it  contains a
+    // number in the middle or an out-of-vocabulary word), then sampling is delayed until a safe
+    // n-gram does appear.
+    /* package for test */ int mMinWordPeriod;
+
+    // Counter for words left to suppress before an n-gram can be sampled.  Reset to mMinWordPeriod
+    // after a sample is taken.
+    /* package for test */ int mWordsUntilSafeToSample;
+
+    public MainLogBuffer(final ResearchLog researchLog) {
+        super(N_GRAM_SIZE);
+        mResearchLog = researchLog;
+        mMinWordPeriod = DEFAULT_NUMBER_OF_WORDS_BETWEEN_SAMPLES + N_GRAM_SIZE;
+        final Random random = new Random();
+        mWordsUntilSafeToSample = random.nextInt(mMinWordPeriod);
+    }
+
+    public void setSuggest(Suggest suggest) {
+        mSuggest = suggest;
+    }
+
+    @Override
+    public void shiftIn(final LogUnit newLogUnit) {
+        super.shiftIn(newLogUnit);
+        if (newLogUnit.hasWord()) {
+            if (mWordsUntilSafeToSample > 0) {
+                mWordsUntilSafeToSample--;
+            }
+        }
+    }
+
+    public void resetWordCounter() {
+        mWordsUntilSafeToSample = mMinWordPeriod;
+    }
+
+    /**
+     * Determines whether the content of the MainLogBuffer can be safely uploaded in its complete
+     * form and still protect the user's privacy.
+     *
+     * The size of the MainLogBuffer is just enough to hold one n-gram, its corrections, and any
+     * non-character data that is typed between words.  The decision about privacy is made based on
+     * the buffer's entire content.  If it is decided that the privacy risks are too great to upload
+     * the contents of this buffer, a censored version of the LogItems may still be uploaded.  E.g.,
+     * the screen orientation and other characteristics about the device can be uploaded without
+     * revealing much about the user.
+     */
+    public boolean isSafeToLog() {
+        // Check that we are not sampling too frequently.  Having sampled recently might disclose
+        // too much of the user's intended meaning.
+        if (mWordsUntilSafeToSample > 0) {
+            return false;
+        }
+        if (mSuggest == null || !mSuggest.hasMainDictionary()) {
+            // Main dictionary is unavailable.  Since we cannot check it, we cannot tell if a word
+            // is out-of-vocabulary or not.  Therefore, we must judge the entire buffer contents to
+            // potentially pose a privacy risk.
+            return false;
+        }
+        // Reload the dictionary in case it has changed (e.g., because the user has changed
+        // languages).
+        final Dictionary dictionary = mSuggest.getMainDictionary();
+        if (dictionary == null) {
+            return false;
+        }
+        // Check each word in the buffer.  If any word poses a privacy threat, we cannot upload the
+        // complete buffer contents in detail.
+        final int length = mLogUnits.size();
+        for (int i = 0; i < length; i++) {
+            final LogUnit logUnit = mLogUnits.get(i);
+            final String word = logUnit.getWord();
+            if (word == null) {
+                // Digits outside words are a privacy threat.
+                if (logUnit.hasDigit()) {
+                    return false;
+                }
+            } else {
+                // Words not in the dictionary are a privacy threat.
+                if (!(dictionary.isValidWord(word))) {
+                    return false;
+                }
+            }
+        }
+        // All checks have passed; this buffer's content can be safely uploaded.
+        return true;
+    }
+
+    @Override
+    protected void onShiftOut(LogUnit logUnit) {
+        if (mResearchLog != null) {
+            mResearchLog.publish(logUnit, false /* isIncludingPrivateData */);
+        }
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/ResearchLog.java b/java/src/com/android/inputmethod/research/ResearchLog.java
index 18bf3c0..71a6d6a 100644
--- a/java/src/com/android/inputmethod/research/ResearchLog.java
+++ b/java/src/com/android/inputmethod/research/ResearchLog.java
@@ -26,7 +26,6 @@
 import com.android.inputmethod.latin.SuggestedWords;
 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
 import com.android.inputmethod.latin.define.ProductionFlag;
-import com.android.inputmethod.research.ResearchLogger.LogUnit;
 
 import java.io.BufferedWriter;
 import java.io.File;
@@ -37,6 +36,7 @@
 import java.util.Map;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -51,21 +51,22 @@
  */
 public class ResearchLog {
     private static final String TAG = ResearchLog.class.getSimpleName();
-    private static final JsonWriter NULL_JSON_WRITER = new JsonWriter(
-            new OutputStreamWriter(new NullOutputStream()));
+    private static final boolean DEBUG = false;
+    private static final long FLUSH_DELAY_IN_MS = 1000 * 5;
+    private static final int ABORT_TIMEOUT_IN_MS = 1000 * 4;
 
-    final ScheduledExecutorService mExecutor;
+    /* package */ final ScheduledExecutorService mExecutor;
     /* package */ final File mFile;
     private JsonWriter mJsonWriter = NULL_JSON_WRITER;
+    // true if at least one byte of data has been written out to the log file.  This must be
+    // remembered because JsonWriter requires that calls matching calls to beginObject and
+    // endObject, as well as beginArray and endArray, and the file is opened lazily, only when
+    // it is certain that data will be written.  Alternatively, the matching call exceptions
+    // could be caught, but this might suppress other errors.
+    private boolean mHasWrittenData = false;
 
-    private int mLoggingState;
-    private static final int LOGGING_STATE_UNSTARTED = 0;
-    private static final int LOGGING_STATE_READY = 1;   // don't create file until necessary
-    private static final int LOGGING_STATE_RUNNING = 2;
-    private static final int LOGGING_STATE_STOPPING = 3;
-    private static final int LOGGING_STATE_STOPPED = 4;
-    private static final long FLUSH_DELAY_IN_MS = 1000 * 5;
-
+    private static final JsonWriter NULL_JSON_WRITER = new JsonWriter(
+            new OutputStreamWriter(new NullOutputStream()));
     private static class NullOutputStream extends OutputStream {
         /** {@inheritDoc} */
         @Override
@@ -84,128 +85,81 @@
         }
     }
 
-    public ResearchLog(File outputFile) {
-        mExecutor = Executors.newSingleThreadScheduledExecutor();
+    public ResearchLog(final File outputFile) {
         if (outputFile == null) {
             throw new IllegalArgumentException();
         }
+        mExecutor = Executors.newSingleThreadScheduledExecutor();
         mFile = outputFile;
-        mLoggingState = LOGGING_STATE_UNSTARTED;
     }
 
-    public synchronized void start() throws IOException {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                mLoggingState = LOGGING_STATE_READY;
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
-                break;
-        }
-    }
-
-    public synchronized void stop() {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                mLoggingState = LOGGING_STATE_STOPPED;
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                mExecutor.submit(new Callable<Object>() {
-                    @Override
-                    public Object call() throws Exception {
-                        try {
-                            mJsonWriter.endArray();
-                            mJsonWriter.flush();
-                            mJsonWriter.close();
-                        } finally {
-                            boolean success = mFile.setWritable(false, false);
-                            mLoggingState = LOGGING_STATE_STOPPED;
-                        }
-                        return null;
+    public synchronized void close() {
+        mExecutor.submit(new Callable<Object>() {
+            @Override
+            public Object call() throws Exception {
+                try {
+                    if (mHasWrittenData) {
+                        mJsonWriter.endArray();
+                        mJsonWriter.flush();
+                        mJsonWriter.close();
+                        mHasWrittenData = false;
                     }
-                });
-                removeAnyScheduledFlush();
-                mExecutor.shutdown();
-                mLoggingState = LOGGING_STATE_STOPPING;
-                break;
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
-        }
-    }
-
-    public boolean isAlive() {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                return true;
-        }
-        return false;
-    }
-
-    public void waitUntilStopped(final int timeoutInMs) throws InterruptedException {
+                } catch (Exception e) {
+                    Log.d(TAG, "error when closing ResearchLog:");
+                    e.printStackTrace();
+                } finally {
+                    if (mFile.exists()) {
+                        mFile.setWritable(false, false);
+                    }
+                }
+                return null;
+            }
+        });
         removeAnyScheduledFlush();
         mExecutor.shutdown();
-        mExecutor.awaitTermination(timeoutInMs, TimeUnit.MILLISECONDS);
     }
 
+    private boolean mIsAbortSuccessful;
+
     public synchronized void abort() {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                mLoggingState = LOGGING_STATE_STOPPED;
-                isAbortSuccessful = true;
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                mExecutor.submit(new Callable<Object>() {
-                    @Override
-                    public Object call() throws Exception {
-                        try {
-                            mJsonWriter.endArray();
-                            mJsonWriter.close();
-                        } finally {
-                            isAbortSuccessful = mFile.delete();
-                        }
-                        return null;
+        mExecutor.submit(new Callable<Object>() {
+            @Override
+            public Object call() throws Exception {
+                try {
+                    if (mHasWrittenData) {
+                        mJsonWriter.endArray();
+                        mJsonWriter.close();
+                        mHasWrittenData = false;
                     }
-                });
-                removeAnyScheduledFlush();
-                mExecutor.shutdown();
-                mLoggingState = LOGGING_STATE_STOPPING;
-                break;
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
-        }
+                } finally {
+                    mIsAbortSuccessful = mFile.delete();
+                }
+                return null;
+            }
+        });
+        removeAnyScheduledFlush();
+        mExecutor.shutdown();
     }
 
-    private boolean isAbortSuccessful;
-    public boolean isAbortSuccessful() {
-        return isAbortSuccessful;
+    public boolean blockingAbort() throws InterruptedException {
+        abort();
+        mExecutor.awaitTermination(ABORT_TIMEOUT_IN_MS, TimeUnit.MILLISECONDS);
+        return mIsAbortSuccessful;
+    }
+
+    public void awaitTermination(int delay, TimeUnit timeUnit) throws InterruptedException {
+        mExecutor.awaitTermination(delay, timeUnit);
     }
 
     /* package */ synchronized void flush() {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                removeAnyScheduledFlush();
-                mExecutor.submit(mFlushCallable);
-                break;
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
-        }
+        removeAnyScheduledFlush();
+        mExecutor.submit(mFlushCallable);
     }
 
-    private Callable<Object> mFlushCallable = new Callable<Object>() {
+    private final Callable<Object> mFlushCallable = new Callable<Object>() {
         @Override
         public Object call() throws Exception {
-            if (mLoggingState == LOGGING_STATE_RUNNING) {
-                mJsonWriter.flush();
-            }
+            mJsonWriter.flush();
             return null;
         }
     };
@@ -224,56 +178,40 @@
         mFlushFuture = mExecutor.schedule(mFlushCallable, FLUSH_DELAY_IN_MS, TimeUnit.MILLISECONDS);
     }
 
-    public synchronized void publishPublicEvents(final LogUnit logUnit) {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                mExecutor.submit(new Callable<Object>() {
-                    @Override
-                    public Object call() throws Exception {
-                        logUnit.publishPublicEventsTo(ResearchLog.this);
-                        scheduleFlush();
-                        return null;
-                    }
-                });
-                break;
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
-        }
-    }
-
-    public synchronized void publishAllEvents(final LogUnit logUnit) {
-        switch (mLoggingState) {
-            case LOGGING_STATE_UNSTARTED:
-                break;
-            case LOGGING_STATE_READY:
-            case LOGGING_STATE_RUNNING:
-                mExecutor.submit(new Callable<Object>() {
-                    @Override
-                    public Object call() throws Exception {
-                        logUnit.publishAllEventsTo(ResearchLog.this);
-                        scheduleFlush();
-                        return null;
-                    }
-                });
-                break;
-            case LOGGING_STATE_STOPPING:
-            case LOGGING_STATE_STOPPED:
+    public synchronized void publish(final LogUnit logUnit, final boolean isIncludingPrivateData) {
+        try {
+            mExecutor.submit(new Callable<Object>() {
+                @Override
+                public Object call() throws Exception {
+                    logUnit.publishTo(ResearchLog.this, isIncludingPrivateData);
+                    scheduleFlush();
+                    return null;
+                }
+            });
+        } catch (RejectedExecutionException e) {
+            // TODO: Add code to record loss of data, and report.
         }
     }
 
     private static final String CURRENT_TIME_KEY = "_ct";
     private static final String UPTIME_KEY = "_ut";
     private static final String EVENT_TYPE_KEY = "_ty";
+
     void outputEvent(final String[] keys, final Object[] values) {
-        // not thread safe.
+        // Not thread safe.
+        if (keys.length == 0) {
+            return;
+        }
+        if (DEBUG) {
+            if (keys.length != values.length + 1) {
+                Log.d(TAG, "Key and Value list sizes do not match. " + keys[0]);
+            }
+        }
         try {
             if (mJsonWriter == NULL_JSON_WRITER) {
                 mJsonWriter = new JsonWriter(new BufferedWriter(new FileWriter(mFile)));
-                mJsonWriter.setLenient(true);
                 mJsonWriter.beginArray();
+                mHasWrittenData = true;
             }
             mJsonWriter.beginObject();
             mJsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis());
@@ -283,8 +221,8 @@
             for (int i = 0; i < length; i++) {
                 mJsonWriter.name(keys[i + 1]);
                 Object value = values[i];
-                if (value instanceof String) {
-                    mJsonWriter.value((String) value);
+                if (value instanceof CharSequence) {
+                    mJsonWriter.value(value.toString());
                 } else if (value instanceof Number) {
                     mJsonWriter.value((Number) value);
                 } else if (value instanceof Boolean) {
@@ -331,14 +269,11 @@
                     SuggestedWords words = (SuggestedWords) value;
                     mJsonWriter.beginObject();
                     mJsonWriter.name("typedWordValid").value(words.mTypedWordValid);
-                    mJsonWriter.name("willAutoCorrect")
-                        .value(words.mWillAutoCorrect);
+                    mJsonWriter.name("willAutoCorrect").value(words.mWillAutoCorrect);
                     mJsonWriter.name("isPunctuationSuggestions")
-                        .value(words.mIsPunctuationSuggestions);
-                    mJsonWriter.name("isObsoleteSuggestions")
-                        .value(words.mIsObsoleteSuggestions);
-                    mJsonWriter.name("isPrediction")
-                        .value(words.mIsPrediction);
+                            .value(words.mIsPunctuationSuggestions);
+                    mJsonWriter.name("isObsoleteSuggestions").value(words.mIsObsoleteSuggestions);
+                    mJsonWriter.name("isPrediction").value(words.mIsPrediction);
                     mJsonWriter.name("words");
                     mJsonWriter.beginArray();
                     final int size = words.size();
@@ -363,8 +298,8 @@
             try {
                 mJsonWriter.close();
             } catch (IllegalStateException e1) {
-                // assume that this is just the json not being terminated properly.
-                // ignore
+                // Assume that this is just the json not being terminated properly.
+                // Ignore
             } catch (IOException e1) {
                 e1.printStackTrace();
             } finally {
diff --git a/java/src/com/android/inputmethod/research/ResearchLogUploader.java b/java/src/com/android/inputmethod/research/ResearchLogUploader.java
deleted file mode 100644
index 3b12130..0000000
--- a/java/src/com/android/inputmethod/research/ResearchLogUploader.java
+++ /dev/null
@@ -1,223 +0,0 @@
-/*
- * 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.Manifest;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.pm.PackageManager;
-import android.net.ConnectivityManager;
-import android.net.NetworkInfo;
-import android.os.BatteryManager;
-import android.util.Log;
-
-import com.android.inputmethod.latin.R;
-import com.android.inputmethod.latin.R.string;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileFilter;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-public final class ResearchLogUploader {
-    private static final String TAG = ResearchLogUploader.class.getSimpleName();
-    private static final int UPLOAD_INTERVAL_IN_MS = 1000 * 60 * 15; // every 15 min
-    private static final int BUF_SIZE = 1024 * 8;
-
-    private final boolean mCanUpload;
-    private final Context mContext;
-    private final File mFilesDir;
-    private final URL mUrl;
-    private final ScheduledExecutorService mExecutor;
-
-    private Runnable doUploadRunnable = new UploadRunnable(null, false);
-
-    public ResearchLogUploader(final Context context, final File filesDir) {
-        mContext = context;
-        mFilesDir = filesDir;
-        final PackageManager packageManager = context.getPackageManager();
-        final boolean hasPermission = packageManager.checkPermission(Manifest.permission.INTERNET,
-                context.getPackageName()) == PackageManager.PERMISSION_GRANTED;
-        if (!hasPermission) {
-            mCanUpload = false;
-            mUrl = null;
-            mExecutor = null;
-            return;
-        }
-        URL tempUrl = null;
-        boolean canUpload = false;
-        ScheduledExecutorService executor = null;
-        try {
-            final String urlString = context.getString(R.string.research_logger_upload_url);
-            if (urlString == null || urlString.equals("")) {
-                return;
-            }
-            tempUrl = new URL(urlString);
-            canUpload = true;
-            executor = Executors.newSingleThreadScheduledExecutor();
-        } catch (MalformedURLException e) {
-            tempUrl = null;
-            e.printStackTrace();
-            return;
-        } finally {
-            mCanUpload = canUpload;
-            mUrl = tempUrl;
-            mExecutor = executor;
-        }
-    }
-
-    public void start() {
-        if (mCanUpload) {
-            Log.d(TAG, "scheduling regular uploading");
-            mExecutor.scheduleWithFixedDelay(doUploadRunnable, UPLOAD_INTERVAL_IN_MS,
-                    UPLOAD_INTERVAL_IN_MS, TimeUnit.MILLISECONDS);
-        } else {
-            Log.d(TAG, "no permission to upload");
-        }
-    }
-
-    public void uploadNow(final Callback callback) {
-        // Perform an immediate upload.  Note that this should happen even if there is
-        // another upload happening right now, as it may have missed the latest changes.
-        // TODO: Reschedule regular upload tests starting from now.
-        if (mCanUpload) {
-            mExecutor.submit(new UploadRunnable(callback, true));
-        }
-    }
-
-    public interface Callback {
-        public void onUploadCompleted(final boolean success);
-    }
-
-    private boolean isExternallyPowered() {
-        final Intent intent = mContext.registerReceiver(null, new IntentFilter(
-                Intent.ACTION_BATTERY_CHANGED));
-        final int pluggedState = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
-        return pluggedState == BatteryManager.BATTERY_PLUGGED_AC
-                || pluggedState == BatteryManager.BATTERY_PLUGGED_USB;
-    }
-
-    private boolean hasWifiConnection() {
-        final ConnectivityManager manager =
-                (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
-        final NetworkInfo wifiInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
-        return wifiInfo.isConnected();
-    }
-
-    class UploadRunnable implements Runnable {
-        private final Callback mCallback;
-        private final boolean mForceUpload;
-
-        public UploadRunnable(final Callback callback, final boolean forceUpload) {
-            mCallback = callback;
-            mForceUpload = forceUpload;
-        }
-
-        @Override
-        public void run() {
-            doUpload();
-        }
-
-        private void doUpload() {
-            if (!mForceUpload && (!isExternallyPowered() || !hasWifiConnection())) {
-                return;
-            }
-            if (mFilesDir == null) {
-                return;
-            }
-            final File[] files = mFilesDir.listFiles(new FileFilter() {
-                @Override
-                public boolean accept(File pathname) {
-                    return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX)
-                            && !pathname.canWrite();
-                }
-            });
-            boolean success = true;
-            if (files.length == 0) {
-                success = false;
-            }
-            for (final File file : files) {
-                if (!uploadFile(file)) {
-                    success = false;
-                }
-            }
-            if (mCallback != null) {
-                mCallback.onUploadCompleted(success);
-            }
-        }
-
-        private boolean uploadFile(File file) {
-            Log.d(TAG, "attempting upload of " + file.getAbsolutePath());
-            boolean success = false;
-            final int contentLength = (int) file.length();
-            HttpURLConnection connection = null;
-            InputStream fileIs = null;
-            try {
-                fileIs = new FileInputStream(file);
-                connection = (HttpURLConnection) mUrl.openConnection();
-                connection.setRequestMethod("PUT");
-                connection.setDoOutput(true);
-                connection.setFixedLengthStreamingMode(contentLength);
-                final OutputStream os = connection.getOutputStream();
-                final byte[] buf = new byte[BUF_SIZE];
-                int numBytesRead;
-                while ((numBytesRead = fileIs.read(buf)) != -1) {
-                    os.write(buf, 0, numBytesRead);
-                }
-                if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
-                    Log.d(TAG, "upload failed: " + connection.getResponseCode());
-                    InputStream netIs = connection.getInputStream();
-                    BufferedReader reader = new BufferedReader(new InputStreamReader(netIs));
-                    String line;
-                    while ((line = reader.readLine()) != null) {
-                        Log.d(TAG, "| " + reader.readLine());
-                    }
-                    reader.close();
-                    return success;
-                }
-                file.delete();
-                success = true;
-                Log.d(TAG, "upload successful");
-            } catch (Exception e) {
-                e.printStackTrace();
-            } finally {
-                if (fileIs != null) {
-                    try {
-                        fileIs.close();
-                    } catch (IOException e) {
-                        e.printStackTrace();
-                    }
-                }
-                if (connection != null) {
-                    connection.disconnect();
-                }
-            }
-            return success;
-        }
-    }
-}
diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java
index 09a22ef..918fcf5 100644
--- a/java/src/com/android/inputmethod/research/ResearchLogger.java
+++ b/java/src/com/android/inputmethod/research/ResearchLogger.java
@@ -18,11 +18,14 @@
 
 import static com.android.inputmethod.latin.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET;
 
+import android.app.AlarmManager;
 import android.app.AlertDialog;
 import android.app.Dialog;
+import android.app.PendingIntent;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.DialogInterface.OnCancelListener;
+import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.SharedPreferences.Editor;
 import android.content.pm.PackageInfo;
@@ -68,11 +71,8 @@
 import com.android.inputmethod.latin.define.ProductionFlag;
 
 import java.io.File;
-import java.io.IOException;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
 import java.util.Date;
-import java.util.List;
 import java.util.Locale;
 import java.util.UUID;
 
@@ -98,24 +98,26 @@
             new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
     private static final boolean IS_SHOWING_INDICATOR = true;
     private static final boolean IS_SHOWING_INDICATOR_CLEARLY = false;
+    public static final int FEEDBACK_WORD_BUFFER_SIZE = 5;
 
     // constants related to specific log points
     private static final String WHITESPACE_SEPARATORS = " \t\n\r";
     private static final int MAX_INPUTVIEW_LENGTH_TO_CAPTURE = 8192; // must be >=1
     private static final String PREF_RESEARCH_LOGGER_UUID_STRING = "pref_research_logger_uuid";
-    private static final int ABORT_TIMEOUT_IN_MS = 10 * 1000; // timeout to notify user
 
     private static final ResearchLogger sInstance = new ResearchLogger();
     // to write to a different filename, e.g., for testing, set mFile before calling start()
     /* package */ File mFilesDir;
     /* package */ String mUUIDString;
     /* package */ ResearchLog mMainResearchLog;
-    // The mIntentionalResearchLog records all events for the session, private or not (excepting
+    // mFeedbackLog records all events for the session, private or not (excepting
     // passwords).  It is written to permanent storage only if the user explicitly commands
     // the system to do so.
-    /* package */ ResearchLog mIntentionalResearchLog;
-    // LogUnits are queued here and released only when the user requests the intentional log.
-    private List<LogUnit> mIntentionalResearchLogQueue = CollectionUtils.newArrayList();
+    // LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are
+    // complete.
+    /* package */ ResearchLog mFeedbackLog;
+    /* package */ MainLogBuffer mMainLogBuffer;
+    /* package */ LogBuffer mFeedbackLogBuffer;
 
     private boolean mIsPasswordView = false;
     private boolean mIsLoggingSuspended = false;
@@ -141,7 +143,10 @@
     private InputMethodService mInputMethodService;
     private final Statistics mStatistics;
 
-    private ResearchLogUploader mResearchLogUploader;
+    private Intent mUploadIntent;
+    private PendingIntent mUploadPendingIntent;
+
+    private LogUnit mCurrentLogUnit = new LogUnit();
 
     private ResearchLogger() {
         mStatistics = Statistics.getInstance();
@@ -181,10 +186,33 @@
                 e.apply();
             }
         }
-        mResearchLogUploader = new ResearchLogUploader(ims, mFilesDir);
-        mResearchLogUploader.start();
         mInputMethodService = ims;
         mPrefs = prefs;
+        mUploadIntent = new Intent(mInputMethodService, UploaderService.class);
+        mUploadPendingIntent = PendingIntent.getService(mInputMethodService, 0, mUploadIntent, 0);
+
+        if (ProductionFlag.IS_EXPERIMENTAL) {
+            scheduleUploadingService(mInputMethodService);
+        }
+    }
+
+    /**
+     * Arrange for the UploaderService to be run on a regular basis.
+     *
+     * Any existing scheduled invocation of UploaderService is removed and rescheduled.  This may
+     * cause problems if this method is called often and frequent updates are required, but since
+     * the user will likely be sleeping at some point, if the interval is less that the expected
+     * sleep duration and this method is not called during that time, the service should be invoked
+     * at some point.
+     */
+    public static void scheduleUploadingService(Context context) {
+        final Intent intent = new Intent(context, UploaderService.class);
+        final PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, 0);
+        final AlarmManager manager =
+                (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+        manager.cancel(pendingIntent);
+        manager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+                UploaderService.RUN_INTERVAL, UploaderService.RUN_INTERVAL, pendingIntent);
     }
 
     private void cleanupLoggingDir(final File dir, final long time) {
@@ -267,6 +295,17 @@
         final Editor e = mPrefs.edit();
         e.putBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, true);
         e.apply();
+        restart();
+    }
+
+    private void setLoggingAllowed(boolean enableLogging) {
+        if (mPrefs == null) {
+            return;
+        }
+        Editor e = mPrefs.edit();
+        e.putBoolean(PREF_USABILITY_STUDY_MODE, enableLogging);
+        e.apply();
+        sIsLogging = enableLogging;
     }
 
     private File createLogFile(File filesDir) {
@@ -315,97 +354,58 @@
             Log.w(TAG, "IME storage directory does not exist.  Cannot start logging.");
             return;
         }
-        try {
-            if (mMainResearchLog == null || !mMainResearchLog.isAlive()) {
-                mMainResearchLog = new ResearchLog(createLogFile(mFilesDir));
-            }
-            mMainResearchLog.start();
-            if (mIntentionalResearchLog == null || !mIntentionalResearchLog.isAlive()) {
-                mIntentionalResearchLog = new ResearchLog(createLogFile(mFilesDir));
-            }
-            mIntentionalResearchLog.start();
-        } catch (IOException e) {
-            Log.w(TAG, "Could not start ResearchLogger.");
+        if (mMainLogBuffer == null) {
+            mMainResearchLog = new ResearchLog(createLogFile(mFilesDir));
+            mMainLogBuffer = new MainLogBuffer(mMainResearchLog);
+            mMainLogBuffer.setSuggest(mSuggest);
+        }
+        if (mFeedbackLogBuffer == null) {
+            mFeedbackLog = new ResearchLog(createLogFile(mFilesDir));
+            // LogBuffer is one more than FEEDBACK_WORD_BUFFER_SIZE, because it must also hold
+            // the feedback LogUnit itself.
+            mFeedbackLogBuffer = new LogBuffer(FEEDBACK_WORD_BUFFER_SIZE + 1);
         }
     }
 
     /* package */ void stop() {
         logStatistics();
-        publishLogUnit(mCurrentLogUnit, true);
-        mCurrentLogUnit = new LogUnit();
+        commitCurrentLogUnit();
 
-        if (mMainResearchLog != null) {
-            mMainResearchLog.stop();
+        if (mMainLogBuffer != null) {
+            publishLogBuffer(mMainLogBuffer, mMainResearchLog, false /* isIncludingPrivateData */);
+            mMainResearchLog.close();
+            mMainLogBuffer = null;
         }
-        if (mIntentionalResearchLog != null) {
-            mIntentionalResearchLog.stop();
+        if (mFeedbackLogBuffer != null) {
+            mFeedbackLog.close();
+            mFeedbackLogBuffer = null;
         }
     }
 
-    private static final String[] EVENTKEYS_STATISTICS = {
-        "Statistics", "charCount", "letterCount", "numberCount", "spaceCount", "deleteOpsCount",
-        "wordCount", "isEmptyUponStarting", "isEmptinessStateKnown", "averageTimeBetweenKeys",
-        "averageTimeBeforeDelete", "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete"
-    };
-    private static void logStatistics() {
-        final ResearchLogger researchLogger = getInstance();
-        final Statistics statistics = researchLogger.mStatistics;
-        final Object[] values = {
-            statistics.mCharCount, statistics.mLetterCount, statistics.mNumberCount,
-            statistics.mSpaceCount, statistics.mDeleteKeyCount,
-            statistics.mWordCount, statistics.mIsEmptyUponStarting,
-            statistics.mIsEmptinessStateKnown, statistics.mKeyCounter.getAverageTime(),
-            statistics.mBeforeDeleteKeyCounter.getAverageTime(),
-            statistics.mDuringRepeatedDeleteKeysCounter.getAverageTime(),
-            statistics.mAfterDeleteKeyCounter.getAverageTime()
-        };
-        researchLogger.enqueueEvent(EVENTKEYS_STATISTICS, values);
-    }
-
-    private void setLoggingAllowed(boolean enableLogging) {
-        if (mPrefs == null) {
-            return;
-        }
-        Editor e = mPrefs.edit();
-        e.putBoolean(PREF_USABILITY_STUDY_MODE, enableLogging);
-        e.apply();
-        sIsLogging = enableLogging;
-    }
-
     public boolean abort() {
         boolean didAbortMainLog = false;
-        if (mMainResearchLog != null) {
-            mMainResearchLog.abort();
+        if (mMainLogBuffer != null) {
+            mMainLogBuffer.clear();
             try {
-                mMainResearchLog.waitUntilStopped(ABORT_TIMEOUT_IN_MS);
+                didAbortMainLog = mMainResearchLog.blockingAbort();
             } catch (InterruptedException e) {
-                // interrupted early.  carry on.
+                // Don't know whether this succeeded or not.  We assume not; this is reported
+                // to the caller.
             }
-            if (mMainResearchLog.isAbortSuccessful()) {
-                didAbortMainLog = true;
-            }
-            mMainResearchLog = null;
+            mMainLogBuffer = null;
         }
-        boolean didAbortIntentionalLog = false;
-        if (mIntentionalResearchLog != null) {
-            mIntentionalResearchLog.abort();
+        boolean didAbortFeedbackLog = false;
+        if (mFeedbackLogBuffer != null) {
+            mFeedbackLogBuffer.clear();
             try {
-                mIntentionalResearchLog.waitUntilStopped(ABORT_TIMEOUT_IN_MS);
+                didAbortFeedbackLog = mFeedbackLog.blockingAbort();
             } catch (InterruptedException e) {
-                // interrupted early.  carry on.
+                // Don't know whether this succeeded or not.  We assume not; this is reported
+                // to the caller.
             }
-            if (mIntentionalResearchLog.isAbortSuccessful()) {
-                didAbortIntentionalLog = true;
-            }
-            mIntentionalResearchLog = null;
+            mFeedbackLogBuffer = null;
         }
-        return didAbortMainLog && didAbortIntentionalLog;
-    }
-
-    /* package */ void flush() {
-        if (mMainResearchLog != null) {
-            mMainResearchLog.flush();
-        }
+        return didAbortMainLog && didAbortFeedbackLog;
     }
 
     private void restart() {
@@ -446,6 +446,8 @@
             abort();
         }
         requestIndicatorRedraw();
+        mPrefs = prefs;
+        prefsChanged(prefs);
     }
 
     public void presentResearchDialog(final LatinIME latinIME) {
@@ -509,79 +511,44 @@
         latinIME.launchKeyboardedDialogActivity(FeedbackActivity.class);
     }
 
-    private ResearchLog mFeedbackLog;
-    private List<LogUnit> mFeedbackQueue;
-    private ResearchLog mSavedMainResearchLog;
-    private ResearchLog mSavedIntentionalResearchLog;
-    private List<LogUnit> mSavedIntentionalResearchLogQueue;
-
-    private void saveLogsForFeedback() {
-        mFeedbackLog = mIntentionalResearchLog;
-        if (mIntentionalResearchLogQueue != null) {
-            mFeedbackQueue = CollectionUtils.newArrayList(mIntentionalResearchLogQueue);
-        } else {
-            mFeedbackQueue = null;
+    private static final String[] EVENTKEYS_FEEDBACK = {
+        "UserTimestamp", "contents"
+    };
+    public void sendFeedback(final String feedbackContents, final boolean includeHistory) {
+        if (mFeedbackLogBuffer == null) {
+            return;
         }
-        mSavedMainResearchLog = mMainResearchLog;
-        mSavedIntentionalResearchLog = mIntentionalResearchLog;
-        mSavedIntentionalResearchLogQueue = mIntentionalResearchLogQueue;
-
-        mMainResearchLog = null;
-        mIntentionalResearchLog = null;
-        mIntentionalResearchLogQueue = CollectionUtils.newArrayList();
+        if (includeHistory) {
+            commitCurrentLogUnit();
+        } else {
+            mFeedbackLogBuffer.clear();
+        }
+        final LogUnit feedbackLogUnit = new LogUnit();
+        final Object[] values = {
+            feedbackContents
+        };
+        feedbackLogUnit.addLogStatement(EVENTKEYS_FEEDBACK, values,
+                false /* isPotentiallyPrivate */);
+        mFeedbackLogBuffer.shiftIn(feedbackLogUnit);
+        publishLogBuffer(mFeedbackLogBuffer, mFeedbackLog, true /* isIncludingPrivateData */);
+        mFeedbackLog.close();
+        uploadNow();
+        mFeedbackLog = new ResearchLog(createLogFile(mFilesDir));
     }
 
-    private static final int LOG_DRAIN_TIMEOUT_IN_MS = 1000 * 5;
-    public void sendFeedback(final String feedbackContents, final boolean includeHistory) {
-        if (includeHistory && mFeedbackLog != null) {
-            try {
-                LogUnit headerLogUnit = new LogUnit();
-                headerLogUnit.addLogAtom(EVENTKEYS_INTENTIONAL_LOG, EVENTKEYS_NULLVALUES, false);
-                mFeedbackLog.publishAllEvents(headerLogUnit);
-                for (LogUnit logUnit : mFeedbackQueue) {
-                    mFeedbackLog.publishAllEvents(logUnit);
-                }
-                userFeedback(mFeedbackLog, feedbackContents);
-                mFeedbackLog.stop();
-                try {
-                    mFeedbackLog.waitUntilStopped(LOG_DRAIN_TIMEOUT_IN_MS);
-                } catch (InterruptedException e) {
-                    e.printStackTrace();
-                }
-                mIntentionalResearchLog = new ResearchLog(createLogFile(mFilesDir));
-                mIntentionalResearchLog.start();
-            } catch (IOException e) {
-                e.printStackTrace();
-            } finally {
-                mIntentionalResearchLogQueue.clear();
-            }
-            mResearchLogUploader.uploadNow(null);
-        } else {
-            // create a separate ResearchLog just for feedback
-            final ResearchLog feedbackLog = new ResearchLog(createLogFile(mFilesDir));
-            try {
-                feedbackLog.start();
-                userFeedback(feedbackLog, feedbackContents);
-                feedbackLog.stop();
-                feedbackLog.waitUntilStopped(LOG_DRAIN_TIMEOUT_IN_MS);
-                mResearchLogUploader.uploadNow(null);
-            } catch (IOException e) {
-                e.printStackTrace();
-            } catch (InterruptedException e) {
-                e.printStackTrace();
-            }
-        }
+    public void uploadNow() {
+        mInputMethodService.startService(mUploadIntent);
     }
 
     public void onLeavingSendFeedbackDialog() {
         mInFeedbackDialog = false;
-        mMainResearchLog = mSavedMainResearchLog;
-        mIntentionalResearchLog = mSavedIntentionalResearchLog;
-        mIntentionalResearchLogQueue = mSavedIntentionalResearchLogQueue;
     }
 
     public void initSuggest(Suggest suggest) {
         mSuggest = suggest;
+        if (mMainLogBuffer != null) {
+            mMainLogBuffer.setSuggest(mSuggest);
+        }
     }
 
     private void setIsPasswordView(boolean isPasswordView) {
@@ -589,7 +556,7 @@
     }
 
     private boolean isAllowedToLog() {
-        return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging;
+        return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog;
     }
 
     public void requestIndicatorRedraw() {
@@ -632,13 +599,8 @@
         }
     }
 
-    private static final String CURRENT_TIME_KEY = "_ct";
-    private static final String UPTIME_KEY = "_ut";
-    private static final String EVENT_TYPE_KEY = "_ty";
     private static final Object[] EVENTKEYS_NULLVALUES = {};
 
-    private LogUnit mCurrentLogUnit = new LogUnit();
-
     /**
      * Buffer a research log event, flagging it as privacy-sensitive.
      *
@@ -654,10 +616,14 @@
             final Object[] values) {
         assert values.length + 1 == keys.length;
         if (isAllowedToLog()) {
-            mCurrentLogUnit.addLogAtom(keys, values, true);
+            mCurrentLogUnit.addLogStatement(keys, values, true /* isPotentiallyPrivate */);
         }
     }
 
+    private void setCurrentLogUnitContainsDigitFlag() {
+        mCurrentLogUnit.setContainsDigit();
+    }
+
     /**
      * Buffer a research log event, flaggint it as not privacy-sensitive.
      *
@@ -673,140 +639,54 @@
     private synchronized void enqueueEvent(final String[] keys, final Object[] values) {
         assert values.length + 1 == keys.length;
         if (isAllowedToLog()) {
-            mCurrentLogUnit.addLogAtom(keys, values, false);
+            mCurrentLogUnit.addLogStatement(keys, values, false /* isPotentiallyPrivate */);
         }
     }
 
-    // Used to track how often words are logged.  Too-frequent logging can leak
-    // semantics, disclosing private data.
-    /* package for test */ static class LoggingFrequencyState {
-        private static final int DEFAULT_WORD_LOG_FREQUENCY = 10;
-        private int mWordsRemainingToSkip;
-        private final int mFrequency;
-
-        /**
-         * Tracks how often words may be uploaded.
-         *
-         * @param frequency 1=Every word, 2=Every other word, etc.
-         */
-        public LoggingFrequencyState(int frequency) {
-            mFrequency = frequency;
-            mWordsRemainingToSkip = mFrequency;
-        }
-
-        public void onWordLogged() {
-            mWordsRemainingToSkip = mFrequency;
-        }
-
-        public void onWordNotLogged() {
-            if (mWordsRemainingToSkip > 1) {
-                mWordsRemainingToSkip--;
-            }
-        }
-
-        public boolean isSafeToLog() {
-            return mWordsRemainingToSkip <= 1;
-        }
-    }
-
-    /* package for test */ LoggingFrequencyState mLoggingFrequencyState =
-            new LoggingFrequencyState(LoggingFrequencyState.DEFAULT_WORD_LOG_FREQUENCY);
-
-    /* package for test */ boolean isPrivacyThreat(String word) {
-        // Current checks:
-        // - Word not in dictionary
-        // - Word contains numbers
-        // - Privacy-safe word not logged recently
-        if (TextUtils.isEmpty(word)) {
-            return false;
-        }
-        if (!mLoggingFrequencyState.isSafeToLog()) {
-            return true;
-        }
-        final int length = word.length();
-        boolean hasLetter = false;
-        for (int i = 0; i < length; i = word.offsetByCodePoints(i, 1)) {
-            final int codePoint = Character.codePointAt(word, i);
-            if (Character.isDigit(codePoint)) {
-                return true;
-            }
-            if (Character.isLetter(codePoint)) {
-                hasLetter = true;
-                break; // Word may contain digits, but will only be allowed if in the dictionary.
-            }
-        }
-        if (hasLetter) {
-            if (mDictionary == null && mSuggest != null && mSuggest.hasMainDictionary()) {
-                mDictionary = mSuggest.getMainDictionary();
-            }
-            if (mDictionary == null) {
-                // Can't access dictionary.  Assume privacy threat.
-                return true;
-            }
-            return !(mDictionary.isValidWord(word));
-        }
-        // No letters, no numbers.  Punctuation, space, or something else.
-        return false;
-    }
-
-    private void onWordComplete(String word) {
-        if (isPrivacyThreat(word)) {
-            publishLogUnit(mCurrentLogUnit, true);
-            mLoggingFrequencyState.onWordNotLogged();
-        } else {
-            publishLogUnit(mCurrentLogUnit, false);
-            mLoggingFrequencyState.onWordLogged();
-        }
-        mCurrentLogUnit = new LogUnit();
-        mStatistics.recordWordEntered();
-    }
-
-    private void publishLogUnit(LogUnit logUnit, boolean isPrivacySensitive) {
-        if (!isAllowedToLog()) {
-            return;
-        }
-        if (mMainResearchLog == null) {
-            return;
-        }
-        if (isPrivacySensitive) {
-            mMainResearchLog.publishPublicEvents(logUnit);
-        } else {
-            mMainResearchLog.publishAllEvents(logUnit);
-        }
-        mIntentionalResearchLogQueue.add(logUnit);
-    }
-
-    /* package */ void publishCurrentLogUnit(ResearchLog researchLog, boolean isPrivacySensitive) {
-        publishLogUnit(mCurrentLogUnit, isPrivacySensitive);
-    }
-
-    static class LogUnit {
-        private final List<String[]> mKeysList = CollectionUtils.newArrayList();
-        private final List<Object[]> mValuesList = CollectionUtils.newArrayList();
-        private final List<Boolean> mIsPotentiallyPrivate = CollectionUtils.newArrayList();
-
-        private void addLogAtom(final String[] keys, final Object[] values,
-                final Boolean isPotentiallyPrivate) {
-            mKeysList.add(keys);
-            mValuesList.add(values);
-            mIsPotentiallyPrivate.add(isPotentiallyPrivate);
-        }
-
-        public void publishPublicEventsTo(ResearchLog researchLog) {
-            final int size = mKeysList.size();
-            for (int i = 0; i < size; i++) {
-                if (!mIsPotentiallyPrivate.get(i)) {
-                    researchLog.outputEvent(mKeysList.get(i), mValuesList.get(i));
+    /* package for test */ void commitCurrentLogUnit() {
+        if (!mCurrentLogUnit.isEmpty()) {
+            if (mMainLogBuffer != null) {
+                mMainLogBuffer.shiftIn(mCurrentLogUnit);
+                if (mMainLogBuffer.isSafeToLog() && mMainResearchLog != null) {
+                    publishLogBuffer(mMainLogBuffer, mMainResearchLog,
+                            true /* isIncludingPrivateData */);
+                    mMainLogBuffer.resetWordCounter();
                 }
             }
+            if (mFeedbackLogBuffer != null) {
+                mFeedbackLogBuffer.shiftIn(mCurrentLogUnit);
+            }
+            mCurrentLogUnit = new LogUnit();
+            Log.d(TAG, "commitCurrentLogUnit");
         }
+    }
 
-        public void publishAllEventsTo(ResearchLog researchLog) {
-            final int size = mKeysList.size();
-            for (int i = 0; i < size; i++) {
-                researchLog.outputEvent(mKeysList.get(i), mValuesList.get(i));
+    /* package for test */ void publishLogBuffer(final LogBuffer logBuffer,
+            final ResearchLog researchLog, final boolean isIncludingPrivateData) {
+        LogUnit logUnit;
+        while ((logUnit = logBuffer.shiftOut()) != null) {
+            researchLog.publish(logUnit, isIncludingPrivateData);
+        }
+    }
+
+    private boolean hasOnlyLetters(final String word) {
+        final int length = word.length();
+        for (int i = 0; i < length; i = word.offsetByCodePoints(i, 1)) {
+            final int codePoint = word.codePointAt(i);
+            if (!Character.isLetter(codePoint)) {
+                return false;
             }
         }
+        return true;
+    }
+
+    private void onWordComplete(final String word) {
+        Log.d(TAG, "onWordComplete: " + word);
+        if (word != null && word.length() > 0 && hasOnlyLetters(word)) {
+            mCurrentLogUnit.setWord(word);
+            mStatistics.recordWordEntered();
+        }
+        commitCurrentLogUnit();
     }
 
     private static int scrubDigitFromCodePoint(int codePoint) {
@@ -859,12 +739,6 @@
         return WORD_REPLACEMENT_STRING;
     }
 
-    // Special methods related to startup, shutdown, logging itself
-
-    private static final String[] EVENTKEYS_INTENTIONAL_LOG = {
-        "IntentionalLog"
-    };
-
     private static final String[] EVENTKEYS_LATINIME_ONSTARTINPUTVIEWINTERNAL = {
         "LatinIMEOnStartInputViewInternal", "uuid", "packageName", "inputType", "imeOptions",
         "fieldId", "display", "model", "prefs", "versionCode", "versionName", "outputFormatVersion"
@@ -872,9 +746,6 @@
     public static void latinIME_onStartInputViewInternal(final EditorInfo editorInfo,
             final SharedPreferences prefs) {
         final ResearchLogger researchLogger = getInstance();
-        if (researchLogger.mInFeedbackDialog) {
-            researchLogger.saveLogsForFeedback();
-        }
         researchLogger.start();
         if (editorInfo != null) {
             final Context context = researchLogger.mInputMethodService;
@@ -906,14 +777,15 @@
         "UserFeedback", "FeedbackContents"
     };
 
-    private void userFeedback(ResearchLog researchLog, String feedbackContents) {
-        // this method is special; it directs the feedbackContents to a particular researchLog
-        final LogUnit logUnit = new LogUnit();
+    private static final String[] EVENTKEYS_PREFS_CHANGED = {
+        "PrefsChanged", "prefs"
+    };
+    public static void prefsChanged(final SharedPreferences prefs) {
+        final ResearchLogger researchLogger = getInstance();
         final Object[] values = {
-            feedbackContents
+            prefs
         };
-        logUnit.addLogAtom(EVENTKEYS_USER_FEEDBACK, values, false);
-        researchLog.publishAllEvents(logUnit);
+        researchLogger.enqueueEvent(EVENTKEYS_PREFS_CHANGED, values);
     }
 
     // Regular logging methods
@@ -950,12 +822,16 @@
         "LatinIMEOnCodeInput", "code", "x", "y"
     };
     public static void latinIME_onCodeInput(final int code, final int x, final int y) {
+        final long time = SystemClock.uptimeMillis();
+        final ResearchLogger researchLogger = getInstance();
         final Object[] values = {
             Keyboard.printableCode(scrubDigitFromCodePoint(code)), x, y
         };
-        final ResearchLogger researchLogger = getInstance();
         researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONCODEINPUT, values);
-        researchLogger.mStatistics.recordChar(code, SystemClock.uptimeMillis());
+        if (Character.isDigit(code)) {
+            researchLogger.setCurrentLogUnitContainsDigitFlag();
+        }
+        researchLogger.mStatistics.recordChar(code, time);
     }
 
     private static final String[] EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS = {
@@ -1020,9 +896,7 @@
             }
             final ResearchLogger researchLogger = getInstance();
             researchLogger.enqueueEvent(EVENTKEYS_LATINIME_ONWINDOWHIDDEN, values);
-            // Play it safe.  Remove privacy-sensitive events.
-            researchLogger.publishLogUnit(researchLogger.mCurrentLogUnit, true);
-            researchLogger.mCurrentLogUnit = new LogUnit();
+            researchLogger.commitCurrentLogUnit();
             getInstance().stop();
         }
     }
@@ -1089,7 +963,11 @@
         final Object[] values = {
             Keyboard.printableCode(scrubDigitFromCodePoint(code))
         };
-        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_SENDKEYCODEPOINT, values);
+        final ResearchLogger researchLogger = getInstance();
+        researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_SENDKEYCODEPOINT, values);
+        if (Character.isDigit(code)) {
+            researchLogger.setCurrentLogUnitContainsDigitFlag();
+        }
     }
 
     private static final String[] EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACE = {
@@ -1227,10 +1105,21 @@
                 EVENTKEYS_RICHINPUTCONNECTION_COMMITCOMPLETION, values);
     }
 
+    // Disabled for privacy-protection reasons.  Because this event comes after
+    // richInputConnection_commitText, which is the event used to separate LogUnits, the
+    // data in this event can be associated with the next LogUnit, revealing information
+    // about the current word even if it was supposed to be suppressed.  The occurrance of
+    // autocorrection can be determined by examining the difference between the text strings in
+    // the last call to richInputConnection_setComposingText before
+    // richInputConnection_commitText, so it's not a data loss.
+    // TODO: Figure out how to log this event without loss of privacy.
+    /*
     private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION = {
-        "RichInputConnectionCommitCorrection", "CorrectionInfo"
+        "RichInputConnectionCommitCorrection", "typedWord", "autoCorrection"
     };
+    */
     public static void richInputConnection_commitCorrection(CorrectionInfo correctionInfo) {
+        /*
         final String typedWord = correctionInfo.getOldText().toString();
         final String autoCorrection = correctionInfo.getNewText().toString();
         final Object[] values = {
@@ -1239,6 +1128,7 @@
         final ResearchLogger researchLogger = getInstance();
         researchLogger.enqueuePotentiallyPrivateEvent(
                 EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION, values);
+        */
     }
 
     private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT = {
@@ -1264,7 +1154,8 @@
         final Object[] values = {
             beforeLength, afterLength
         };
-        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, values);
+        getInstance().enqueuePotentiallyPrivateEvent(
+                EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, values);
     }
 
     private static final String[] EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT = {
@@ -1294,7 +1185,8 @@
             keyEvent.getAction(),
             keyEvent.getKeyCode()
         };
-        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT, values);
+        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT,
+                values);
     }
 
     private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT = {
@@ -1302,10 +1194,14 @@
     };
     public static void richInputConnection_setComposingText(final CharSequence text,
             final int newCursorPosition) {
+        if (text == null) {
+            throw new RuntimeException("setComposingText is null");
+        }
         final Object[] values = {
             text, newCursorPosition
         };
-        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT, values);
+        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT,
+                values);
     }
 
     private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION = {
@@ -1315,7 +1211,8 @@
         final Object[] values = {
             from, to
         };
-        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION, values);
+        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION,
+                values);
     }
 
     private static final String[] EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT = {
@@ -1350,4 +1247,24 @@
     public void userTimestamp() {
         getInstance().enqueueEvent(EVENTKEYS_USER_TIMESTAMP, EVENTKEYS_NULLVALUES);
     }
+
+    private static final String[] EVENTKEYS_STATISTICS = {
+        "Statistics", "charCount", "letterCount", "numberCount", "spaceCount", "deleteOpsCount",
+        "wordCount", "isEmptyUponStarting", "isEmptinessStateKnown", "averageTimeBetweenKeys",
+        "averageTimeBeforeDelete", "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete"
+    };
+    private static void logStatistics() {
+        final ResearchLogger researchLogger = getInstance();
+        final Statistics statistics = researchLogger.mStatistics;
+        final Object[] values = {
+            statistics.mCharCount, statistics.mLetterCount, statistics.mNumberCount,
+            statistics.mSpaceCount, statistics.mDeleteKeyCount,
+            statistics.mWordCount, statistics.mIsEmptyUponStarting,
+            statistics.mIsEmptinessStateKnown, statistics.mKeyCounter.getAverageTime(),
+            statistics.mBeforeDeleteKeyCounter.getAverageTime(),
+            statistics.mDuringRepeatedDeleteKeysCounter.getAverageTime(),
+            statistics.mAfterDeleteKeyCounter.getAverageTime()
+        };
+        researchLogger.enqueueEvent(EVENTKEYS_STATISTICS, values);
+    }
 }
diff --git a/java/src/com/android/inputmethod/research/Statistics.java b/java/src/com/android/inputmethod/research/Statistics.java
index 4a2cd07..eab465a 100644
--- a/java/src/com/android/inputmethod/research/Statistics.java
+++ b/java/src/com/android/inputmethod/research/Statistics.java
@@ -66,8 +66,8 @@
 
     // To account for the interruptions when the user's attention is directed elsewhere, times
     // longer than MIN_TYPING_INTERMISSION are not counted when estimating this statistic.
-    public static final int MIN_TYPING_INTERMISSION = 5 * 1000;  // in milliseconds
-    public static final int MIN_DELETION_INTERMISSION = 15 * 1000;  // in milliseconds
+    public static final int MIN_TYPING_INTERMISSION = 2 * 1000;  // in milliseconds
+    public static final int MIN_DELETION_INTERMISSION = 10 * 1000;  // in milliseconds
 
     // The last time that a tap was performed
     private long mLastTapTime;
diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java
new file mode 100644
index 0000000..7a57490
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/UploaderService.java
@@ -0,0 +1,191 @@
+/*
+ * 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.Manifest;
+import android.app.AlarmManager;
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.BatteryManager;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+public final class UploaderService extends IntentService {
+    private static final String TAG = UploaderService.class.getSimpleName();
+    public static final long RUN_INTERVAL = AlarmManager.INTERVAL_HOUR;
+    private static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName()
+            + ".extra.UPLOAD_UNCONDITIONALLY";
+    private static final int BUF_SIZE = 1024 * 8;
+    protected static final int TIMEOUT_IN_MS = 1000 * 4;
+
+    private boolean mCanUpload;
+    private File mFilesDir;
+    private URL mUrl;
+
+    public UploaderService() {
+        super("Research Uploader Service");
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        mCanUpload = false;
+        mFilesDir = null;
+        mUrl = null;
+
+        final PackageManager packageManager = getPackageManager();
+        final boolean hasPermission = packageManager.checkPermission(Manifest.permission.INTERNET,
+                getPackageName()) == PackageManager.PERMISSION_GRANTED;
+        if (!hasPermission) {
+            return;
+        }
+
+        try {
+            final String urlString = getString(R.string.research_logger_upload_url);
+            if (urlString == null || urlString.equals("")) {
+                return;
+            }
+            mFilesDir = getFilesDir();
+            mUrl = new URL(urlString);
+            mCanUpload = true;
+        } catch (MalformedURLException e) {
+            e.printStackTrace();
+        }
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        if (!mCanUpload) {
+            return;
+        }
+        boolean isUploadingUnconditionally = false;
+        Bundle bundle = intent.getExtras();
+        if (bundle != null && bundle.containsKey(EXTRA_UPLOAD_UNCONDITIONALLY)) {
+            isUploadingUnconditionally = bundle.getBoolean(EXTRA_UPLOAD_UNCONDITIONALLY);
+        }
+        doUpload(isUploadingUnconditionally);
+    }
+
+    private boolean isExternallyPowered() {
+        final Intent intent = registerReceiver(null, new IntentFilter(
+                Intent.ACTION_BATTERY_CHANGED));
+        final int pluggedState = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
+        return pluggedState == BatteryManager.BATTERY_PLUGGED_AC
+                || pluggedState == BatteryManager.BATTERY_PLUGGED_USB;
+    }
+
+    private boolean hasWifiConnection() {
+        final ConnectivityManager manager =
+                (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+        final NetworkInfo wifiInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+        return wifiInfo.isConnected();
+    }
+
+    private void doUpload(final boolean isUploadingUnconditionally) {
+        if (!isUploadingUnconditionally && (!isExternallyPowered() || !hasWifiConnection())) {
+            return;
+        }
+        if (mFilesDir == null) {
+            return;
+        }
+        final File[] files = mFilesDir.listFiles(new FileFilter() {
+            @Override
+            public boolean accept(File pathname) {
+                return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX)
+                        && !pathname.canWrite();
+            }
+        });
+        boolean success = true;
+        if (files.length == 0) {
+            success = false;
+        }
+        for (final File file : files) {
+            if (!uploadFile(file)) {
+                success = false;
+            }
+        }
+    }
+
+    private boolean uploadFile(File file) {
+        Log.d(TAG, "attempting upload of " + file.getAbsolutePath());
+        boolean success = false;
+        final int contentLength = (int) file.length();
+        HttpURLConnection connection = null;
+        InputStream fileInputStream = null;
+        try {
+            fileInputStream = new FileInputStream(file);
+            connection = (HttpURLConnection) mUrl.openConnection();
+            connection.setRequestMethod("PUT");
+            connection.setDoOutput(true);
+            connection.setFixedLengthStreamingMode(contentLength);
+            final OutputStream os = connection.getOutputStream();
+            final byte[] buf = new byte[BUF_SIZE];
+            int numBytesRead;
+            while ((numBytesRead = fileInputStream.read(buf)) != -1) {
+                os.write(buf, 0, numBytesRead);
+            }
+            if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+                Log.d(TAG, "upload failed: " + connection.getResponseCode());
+                InputStream netInputStream = connection.getInputStream();
+                BufferedReader reader = new BufferedReader(new InputStreamReader(netInputStream));
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    Log.d(TAG, "| " + reader.readLine());
+                }
+                reader.close();
+                return success;
+            }
+            file.delete();
+            success = true;
+            Log.d(TAG, "upload successful");
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (fileInputStream != null) {
+                try {
+                    fileInputStream.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+        return success;
+    }
+}
diff --git a/tests/src/com/android/inputmethod/latin/InputPointersTests.java b/tests/src/com/android/inputmethod/latin/InputPointersTests.java
index 6f04f3e..cc55076 100644
--- a/tests/src/com/android/inputmethod/latin/InputPointersTests.java
+++ b/tests/src/com/android/inputmethod/latin/InputPointersTests.java
@@ -18,6 +18,8 @@
 
 import android.test.AndroidTestCase;
 
+import java.util.Arrays;
+
 public class InputPointersTests extends AndroidTestCase {
     private static final int DEFAULT_CAPACITY = 48;
 
@@ -162,6 +164,61 @@
                 src.getTimes(), 0, dst.getTimes(), dstLen, srcLen);
     }
 
+    public void testAppendResizableIntArray() {
+        final int srcLen = 100;
+        final int srcPointerId = 1;
+        final int[] srcPointerIds = new int[srcLen];
+        Arrays.fill(srcPointerIds, srcPointerId);
+        final ResizableIntArray srcTimes = new ResizableIntArray(DEFAULT_CAPACITY);
+        final ResizableIntArray srcXCoords = new ResizableIntArray(DEFAULT_CAPACITY);
+        final ResizableIntArray srcYCoords= new ResizableIntArray(DEFAULT_CAPACITY);
+        for (int i = 0; i < srcLen; i++) {
+            srcTimes.add(i * 2);
+            srcXCoords.add(i * 3);
+            srcYCoords.add(i * 4);
+        }
+        final int dstLen = 50;
+        final InputPointers dst = new InputPointers(DEFAULT_CAPACITY);
+        for (int i = 0; i < dstLen; i++) {
+            final int value = -i - 1;
+            dst.addPointer(value * 4, value * 3, value * 2, value);
+        }
+        final InputPointers dstCopy = new InputPointers(DEFAULT_CAPACITY);
+        dstCopy.copy(dst);
+
+        dst.append(srcPointerId, srcTimes, srcXCoords, srcYCoords, 0, 0);
+        assertEquals("size after append zero", dstLen, dst.getPointerSize());
+        assertArrayEquals("xCoordinates after append zero",
+                dstCopy.getXCoordinates(), 0, dst.getXCoordinates(), 0, dstLen);
+        assertArrayEquals("yCoordinates after append zero",
+                dstCopy.getYCoordinates(), 0, dst.getYCoordinates(), 0, dstLen);
+        assertArrayEquals("pointerIds after append zero",
+                dstCopy.getPointerIds(), 0, dst.getPointerIds(), 0, dstLen);
+        assertArrayEquals("times after append zero",
+                dstCopy.getTimes(), 0, dst.getTimes(), 0, dstLen);
+
+        dst.append(srcPointerId, srcTimes, srcXCoords, srcYCoords, 0, srcLen);
+        assertEquals("size after append", dstLen + srcLen, dst.getPointerSize());
+        assertTrue("primitive length after append",
+                dst.getPointerIds().length >= dstLen + srcLen);
+        assertArrayEquals("original xCoordinates values after append",
+                dstCopy.getXCoordinates(), 0, dst.getXCoordinates(), 0, dstLen);
+        assertArrayEquals("original yCoordinates values after append",
+                dstCopy.getYCoordinates(), 0, dst.getYCoordinates(), 0, dstLen);
+        assertArrayEquals("original pointerIds values after append",
+                dstCopy.getPointerIds(), 0, dst.getPointerIds(), 0, dstLen);
+        assertArrayEquals("original times values after append",
+                dstCopy.getTimes(), 0, dst.getTimes(), 0, dstLen);
+        assertArrayEquals("appended xCoordinates values after append",
+                srcXCoords.getPrimitiveArray(), 0, dst.getXCoordinates(), dstLen, srcLen);
+        assertArrayEquals("appended yCoordinates values after append",
+                srcYCoords.getPrimitiveArray(), 0, dst.getYCoordinates(), dstLen, srcLen);
+        assertArrayEquals("appended pointerIds values after append",
+                srcPointerIds, 0, dst.getPointerIds(), dstLen, srcLen);
+        assertArrayEquals("appended times values after append",
+                srcTimes.getPrimitiveArray(), 0, dst.getTimes(), dstLen, srcLen);
+    }
+
     private static void assertArrayEquals(String message, int[] expecteds, int expectedPos,
             int[] actuals, int actualPos, int length) {
         if (expecteds == null && actuals == null) {