am 99927f86: am de8a9a82: Small cleanups

* commit '99927f865faff5a9f31de315620447fd963f3dff':
  Small cleanups
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/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index 884e6db..6fbd2a2 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -907,13 +907,13 @@
                 }
             }
         }
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_onDisplayCompletions(applicationSpecifiedCompletions);
-        }
         if (!mCurrentSettings.isApplicationSpecifiedCompletionsOn()) return;
         mApplicationSpecifiedCompletions = applicationSpecifiedCompletions;
         if (applicationSpecifiedCompletions == null) {
             clearSuggestionStrip();
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.latinIME_onDisplayCompletions(null);
+            }
             return;
         }
 
@@ -935,6 +935,9 @@
         // this case? This says to keep whatever the user typed.
         mWordComposer.setAutoCorrection(mWordComposer.getTypedWord());
         setSuggestionStripShown(true);
+        if (ProductionFlag.IS_EXPERIMENTAL) {
+            ResearchLogger.latinIME_onDisplayCompletions(applicationSpecifiedCompletions);
+        }
     }
 
     private void setSuggestionStripShownInternal(boolean shown, boolean needsInputViewShown) {
@@ -1055,9 +1058,6 @@
         final CharSequence typedWord = mWordComposer.getTypedWord();
         if (typedWord.length() > 0) {
             mConnection.commitText(typedWord, 1);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_commitText(typedWord);
-            }
             final CharSequence prevWord = addToUserHistoryDictionary(typedWord);
             mLastComposedWord = mWordComposer.commitWord(
                     LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, typedWord.toString(),
@@ -1099,12 +1099,9 @@
         if (lastTwo != null && lastTwo.length() == 2
                 && lastTwo.charAt(0) == Keyboard.CODE_SPACE) {
             mConnection.deleteSurroundingText(2, 0);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_deleteSurroundingText(2);
-            }
             mConnection.commitText(lastTwo.charAt(1) + " ", 1);
             if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_swapSwapperAndSpaceWhileInBatchEdit();
+                ResearchLogger.latinIME_swapSwapperAndSpace();
             }
             mKeyboardSwitcher.updateShiftState();
         }
@@ -1121,9 +1118,6 @@
             mHandler.cancelDoubleSpacesTimer();
             mConnection.deleteSurroundingText(2, 0);
             mConnection.commitText(". ", 1);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_doubleSpaceAutoPeriod();
-            }
             mKeyboardSwitcher.updateShiftState();
             return true;
         }
@@ -1187,9 +1181,6 @@
 
     private void performEditorAction(int actionId) {
         mConnection.performEditorAction(actionId);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_performEditorAction(actionId);
-        }
     }
 
     private void handleLanguageSwitchKey() {
@@ -1226,6 +1217,9 @@
         // For backward compatibility. See {@link InputMethodService#sendKeyChar(char)}.
         if (code >= '0' && code <= '9') {
             super.sendKeyChar((char)code);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.latinIME_sendKeyCodePoint(code);
+            }
             return;
         }
 
@@ -1242,9 +1236,6 @@
             final String text = new String(new int[] { code }, 0, 1);
             mConnection.commitText(text, text.length());
         }
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_sendKeyCodePoint(code);
-        }
     }
 
     // Implementation of {@link KeyboardActionListener}.
@@ -1256,11 +1247,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
@@ -1342,6 +1328,9 @@
             mLastComposedWord.deactivate();
         mEnteredText = null;
         mConnection.endBatchEdit();
+        if (ProductionFlag.IS_EXPERIMENTAL) {
+            ResearchLogger.latinIME_onCodeInput(primaryCode, x, y);
+        }
     }
 
     // Called from PointerTracker through the KeyboardActionListener interface
@@ -1355,9 +1344,6 @@
             sendKeyCodePoint(Keyboard.CODE_SPACE);
         }
         mConnection.commitText(text, 1);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_commitText(text);
-        }
         mConnection.endBatchEdit();
         mKeyboardSwitcher.updateShiftState();
         mKeyboardSwitcher.onCodeInput(Keyboard.CODE_OUTPUT_TEXT);
@@ -1456,9 +1442,6 @@
             // like the smiley key or the .com key.
             final int length = mEnteredText.length();
             mConnection.deleteSurroundingText(length, 0);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_deleteSurroundingText(length);
-            }
             // If we have mEnteredText, then we know that mHasUncommittedTypedChars == false.
             // In addition we know that spaceState is false, and that we should not be
             // reverting any autocorrect at this point. So we can safely return.
@@ -1478,9 +1461,6 @@
                 mHandler.postUpdateSuggestionStrip();
             } else {
                 mConnection.deleteSurroundingText(1, 0);
-                if (ProductionFlag.IS_EXPERIMENTAL) {
-                    ResearchLogger.latinIME_deleteSurroundingText(1);
-                }
             }
         } else {
             if (mLastComposedWord.canRevertCommit()) {
@@ -1509,9 +1489,6 @@
                 final int lengthToDelete = mLastSelectionEnd - mLastSelectionStart;
                 mConnection.setSelection(mLastSelectionEnd, mLastSelectionEnd);
                 mConnection.deleteSurroundingText(lengthToDelete, 0);
-                if (ProductionFlag.IS_EXPERIMENTAL) {
-                    ResearchLogger.latinIME_deleteSurroundingText(lengthToDelete);
-                }
             } else {
                 // There is no selection, just delete one character.
                 if (NOT_A_CURSOR_POSITION == mLastSelectionEnd) {
@@ -1530,14 +1507,8 @@
                 } else {
                     mConnection.deleteSurroundingText(1, 0);
                 }
-                if (ProductionFlag.IS_EXPERIMENTAL) {
-                    ResearchLogger.latinIME_deleteSurroundingText(1);
-                }
                 if (mDeleteCount > DELETE_ACCELERATE_AT) {
                     mConnection.deleteSurroundingText(1, 0);
-                    if (ProductionFlag.IS_EXPERIMENTAL) {
-                        ResearchLogger.latinIME_deleteSurroundingText(1);
-                    }
                 }
             }
             if (mCurrentSettings.isSuggestionsRequested(mDisplayOrientation)) {
@@ -1862,10 +1833,6 @@
                         + "is empty? Impossible! I must commit suicide.");
             }
             Utils.Stats.onAutoCorrection(typedWord, autoCorrection.toString(), separatorCodePoint);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_commitCurrentAutoCorrection(typedWord,
-                        autoCorrection.toString());
-            }
             mExpectingUpdateSelection = true;
             commitChosenWord(autoCorrection, LastComposedWord.COMMIT_TYPE_DECIDED_WORD,
                     separatorCodePoint);
@@ -1891,13 +1858,13 @@
             // So, LatinImeLogger logs "" as a user's input.
             LatinImeLogger.logOnManualSuggestion("", suggestion.toString(), index, suggestedWords);
             // Rely on onCodeInput to do the complicated swapping/stripping logic consistently.
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_punctuationSuggestion(index, suggestion, x, y);
-            }
             final int primaryCode = suggestion.charAt(0);
             onCodeInput(primaryCode,
                     KeyboardActionListener.SUGGESTION_STRIP_COORDINATE,
                     KeyboardActionListener.SUGGESTION_STRIP_COORDINATE);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.latinIME_punctuationSuggestion(index, suggestion, x, y);
+            }
             return;
         }
 
@@ -1924,10 +1891,6 @@
             final CompletionInfo completionInfo = mApplicationSpecifiedCompletions[index];
             mConnection.commitCompletion(completionInfo);
             mConnection.endBatchEdit();
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_pickApplicationSpecifiedCompletion(index,
-                        completionInfo.getText(), x, y);
-            }
             return;
         }
 
@@ -1936,12 +1899,12 @@
         final String replacedWord = mWordComposer.getTypedWord().toString();
         LatinImeLogger.logOnManualSuggestion(replacedWord,
                 suggestion.toString(), index, suggestedWords);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_pickSuggestionManually(replacedWord, index, suggestion, x, y);
-        }
         mExpectingUpdateSelection = true;
         commitChosenWord(suggestion, LastComposedWord.COMMIT_TYPE_MANUAL_PICK,
                 LastComposedWord.NOT_A_SEPARATOR);
+        if (ProductionFlag.IS_EXPERIMENTAL) {
+            ResearchLogger.latinIME_pickSuggestionManually(replacedWord, index, suggestion, x, y);
+        }
         mConnection.endBatchEdit();
         // Don't allow cancellation of manual pick
         mLastComposedWord.deactivate();
@@ -1976,9 +1939,6 @@
         final SuggestedWords suggestedWords = mSuggestionStripView.getSuggestions();
         mConnection.commitText(SuggestionSpanUtils.getTextWithSuggestionSpan(
                 this, chosenWord, suggestedWords, mIsMainDictionaryAvailable), 1);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_commitText(chosenWord);
-        }
         // Add the word to the user history dictionary
         final CharSequence prevWord = addToUserHistoryDictionary(chosenWord);
         // TODO: figure out here if this is an auto-correct or if the best word is actually
@@ -2045,9 +2005,6 @@
         mWordComposer.setComposingWord(word, mKeyboardSwitcher.getKeyboard());
         final int length = word.length();
         mConnection.deleteSurroundingText(length, 0);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_deleteSurroundingText(length);
-        }
         mConnection.setComposingText(word, 1);
         mHandler.postUpdateSuggestionStrip();
     }
@@ -2075,9 +2032,6 @@
             }
         }
         mConnection.deleteSurroundingText(deleteLength, 0);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_deleteSurroundingText(deleteLength);
-        }
         if (!TextUtils.isEmpty(previousWord) && !TextUtils.isEmpty(committedWord)) {
             mUserHistoryDictionary.cancelAddingUserHistory(
                     previousWord.toString(), committedWord.toString());
diff --git a/java/src/com/android/inputmethod/latin/RichInputConnection.java b/java/src/com/android/inputmethod/latin/RichInputConnection.java
index 8b4c173..41e59e9 100644
--- a/java/src/com/android/inputmethod/latin/RichInputConnection.java
+++ b/java/src/com/android/inputmethod/latin/RichInputConnection.java
@@ -55,7 +55,9 @@
     public void beginBatchEdit() {
         if (++mNestLevel == 1) {
             mIC = mParent.getCurrentInputConnection();
-            if (null != mIC) mIC.beginBatchEdit();
+            if (null != mIC) {
+                mIC.beginBatchEdit();
+            }
         } else {
             if (DBG) {
                 throw new RuntimeException("Nest level too deep");
@@ -66,7 +68,9 @@
     }
     public void endBatchEdit() {
         if (mNestLevel <= 0) Log.e(TAG, "Batch edit not in progress!"); // TODO: exception instead
-        if (--mNestLevel == 0 && null != mIC) mIC.endBatchEdit();
+        if (--mNestLevel == 0 && null != mIC) {
+            mIC.endBatchEdit();
+        }
     }
 
     private void checkBatchEdit() {
@@ -79,12 +83,22 @@
 
     public void finishComposingText() {
         checkBatchEdit();
-        if (null != mIC) mIC.finishComposingText();
+        if (null != mIC) {
+            mIC.finishComposingText();
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_finishComposingText();
+            }
+        }
     }
 
     public void commitText(final CharSequence text, final int i) {
         checkBatchEdit();
-        if (null != mIC) mIC.commitText(text, i);
+        if (null != mIC) {
+            mIC.commitText(text, i);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_commitText(text, i);
+            }
+        }
     }
 
     public int getCursorCapsMode(final int inputType) {
@@ -107,37 +121,72 @@
 
     public void deleteSurroundingText(final int i, final int j) {
         checkBatchEdit();
-        if (null != mIC) mIC.deleteSurroundingText(i, j);
+        if (null != mIC) {
+            mIC.deleteSurroundingText(i, j);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_deleteSurroundingText(i, j);
+            }
+        }
     }
 
     public void performEditorAction(final int actionId) {
         mIC = mParent.getCurrentInputConnection();
-        if (null != mIC) mIC.performEditorAction(actionId);
+        if (null != mIC) {
+            mIC.performEditorAction(actionId);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_performEditorAction(actionId);
+            }
+        }
     }
 
     public void sendKeyEvent(final KeyEvent keyEvent) {
         checkBatchEdit();
-        if (null != mIC) mIC.sendKeyEvent(keyEvent);
+        if (null != mIC) {
+            mIC.sendKeyEvent(keyEvent);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_sendKeyEvent(keyEvent);
+            }
+        }
     }
 
     public void setComposingText(final CharSequence text, final int i) {
         checkBatchEdit();
-        if (null != mIC) mIC.setComposingText(text, i);
+        if (null != mIC) {
+            mIC.setComposingText(text, i);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_setComposingText(text, i);
+            }
+        }
     }
 
     public void setSelection(final int from, final int to) {
         checkBatchEdit();
-        if (null != mIC) mIC.setSelection(from, to);
+        if (null != mIC) {
+            mIC.setSelection(from, to);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_setSelection(from, to);
+            }
+        }
     }
 
     public void commitCorrection(final CorrectionInfo correctionInfo) {
         checkBatchEdit();
-        if (null != mIC) mIC.commitCorrection(correctionInfo);
+        if (null != mIC) {
+            mIC.commitCorrection(correctionInfo);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_commitCorrection(correctionInfo);
+            }
+        }
     }
 
     public void commitCompletion(final CompletionInfo completionInfo) {
         checkBatchEdit();
-        if (null != mIC) mIC.commitCompletion(completionInfo);
+        if (null != mIC) {
+            mIC.commitCompletion(completionInfo);
+            if (ProductionFlag.IS_EXPERIMENTAL) {
+                ResearchLogger.richInputConnection_commitCompletion(completionInfo);
+            }
+        }
     }
 
     public CharSequence getNthPreviousWord(final String sentenceSeperators, final int n) {
@@ -315,9 +364,6 @@
         if (lastOne != null && lastOne.length() == 1
                 && lastOne.charAt(0) == Keyboard.CODE_SPACE) {
             deleteSurroundingText(1, 0);
-            if (ProductionFlag.IS_EXPERIMENTAL) {
-                ResearchLogger.latinIME_deleteSurroundingText(1);
-            }
         }
     }
 
@@ -382,13 +428,7 @@
             return false;
         }
         deleteSurroundingText(2, 0);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_deleteSurroundingText(2);
-        }
         commitText("  ", 1);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_revertDoubleSpaceWhileInBatchEdit();
-        }
         return true;
     }
 
@@ -409,13 +449,7 @@
             return false;
         }
         deleteSurroundingText(2, 0);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_deleteSurroundingText(2);
-        }
         commitText(" " + textBeforeCursor.subSequence(0, 1), 1);
-        if (ProductionFlag.IS_EXPERIMENTAL) {
-            ResearchLogger.latinIME_revertSwapPunctuation();
-        }
         return true;
     }
 }
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..65f5f83
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogBuffer.java
@@ -0,0 +1,111 @@
+/*
+ * 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 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 = new LinkedList<LogUnit>();
+        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..8a80664
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogUnit.java
@@ -0,0 +1,81 @@
+/*
+ * 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 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 = new ArrayList<String[]>();
+    private final ArrayList<Object[]> mValuesList = new ArrayList<Object[]>();
+    private final ArrayList<Boolean> mIsPotentiallyPrivate = new ArrayList<Boolean>();
+    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 cf6f31a..4fee9f6 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;
@@ -34,15 +37,18 @@
 import android.inputmethodservice.InputMethodService;
 import android.os.Build;
 import android.os.IBinder;
+import android.os.SystemClock;
 import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.util.Log;
+import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.Window;
 import android.view.WindowManager;
 import android.view.inputmethod.CompletionInfo;
+import android.view.inputmethod.CorrectionInfo;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
 import android.widget.Button;
@@ -64,11 +70,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;
 
@@ -94,24 +97,21 @@
             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
-    // 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 = new ArrayList<LogUnit>();
+    /* package */ ResearchLog mFeedbackLog;
+    /* package */ MainLogBuffer mMainLogBuffer;
+    /* package */ LogBuffer mFeedbackLogBuffer;
 
     private boolean mIsPasswordView = false;
     private boolean mIsLoggingSuspended = false;
@@ -135,10 +135,15 @@
     private Dictionary mDictionary;
     private KeyboardSwitcher mKeyboardSwitcher;
     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();
     }
 
     public static ResearchLogger getInstance() {
@@ -176,11 +181,34 @@
                 e.apply();
             }
         }
-        mResearchLogUploader = new ResearchLogUploader(ims, mFilesDir);
-        mResearchLogUploader.start();
         mKeyboardSwitcher = keyboardSwitcher;
         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) {
@@ -257,50 +285,7 @@
         final Editor e = mPrefs.edit();
         e.putBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, true);
         e.apply();
-    }
-
-    private File createLogFile(File filesDir) {
-        final StringBuilder sb = new StringBuilder();
-        sb.append(FILENAME_PREFIX).append('-');
-        sb.append(mUUIDString).append('-');
-        sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
-        sb.append(FILENAME_SUFFIX);
-        return new File(filesDir, sb.toString());
-    }
-
-    private void start() {
-        maybeShowSplashScreen();
-        updateSuspendedState();
-        requestIndicatorRedraw();
-        if (!isAllowedToLog()) {
-            // Log.w(TAG, "not in usability mode; not logging");
-            return;
-        }
-        if (mFilesDir == null || !mFilesDir.exists()) {
-            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.");
-        }
-    }
-
-    /* package */ void stop() {
-        if (mMainResearchLog != null) {
-            mMainResearchLog.stop();
-        }
-        if (mIntentionalResearchLog != null) {
-            mIntentionalResearchLog.stop();
-        }
+        restart();
     }
 
     private void setLoggingAllowed(boolean enableLogging) {
@@ -313,40 +298,104 @@
         sIsLogging = enableLogging;
     }
 
-    public boolean abort() {
-        boolean didAbortMainLog = false;
-        if (mMainResearchLog != null) {
-            mMainResearchLog.abort();
-            try {
-                mMainResearchLog.waitUntilStopped(ABORT_TIMEOUT_IN_MS);
-            } catch (InterruptedException e) {
-                // interrupted early.  carry on.
-            }
-            if (mMainResearchLog.isAbortSuccessful()) {
-                didAbortMainLog = true;
-            }
-            mMainResearchLog = null;
-        }
-        boolean didAbortIntentionalLog = false;
-        if (mIntentionalResearchLog != null) {
-            mIntentionalResearchLog.abort();
-            try {
-                mIntentionalResearchLog.waitUntilStopped(ABORT_TIMEOUT_IN_MS);
-            } catch (InterruptedException e) {
-                // interrupted early.  carry on.
-            }
-            if (mIntentionalResearchLog.isAbortSuccessful()) {
-                didAbortIntentionalLog = true;
-            }
-            mIntentionalResearchLog = null;
-        }
-        return didAbortMainLog && didAbortIntentionalLog;
+    private File createLogFile(File filesDir) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(FILENAME_PREFIX).append('-');
+        sb.append(mUUIDString).append('-');
+        sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
+        sb.append(FILENAME_SUFFIX);
+        return new File(filesDir, sb.toString());
     }
 
-    /* package */ void flush() {
-        if (mMainResearchLog != null) {
-            mMainResearchLog.flush();
+    private void checkForEmptyEditor() {
+        if (mInputMethodService == null) {
+            return;
         }
+        final InputConnection ic = mInputMethodService.getCurrentInputConnection();
+        if (ic == null) {
+            return;
+        }
+        final CharSequence textBefore = ic.getTextBeforeCursor(1, 0);
+        if (!TextUtils.isEmpty(textBefore)) {
+            mStatistics.setIsEmptyUponStarting(false);
+            return;
+        }
+        final CharSequence textAfter = ic.getTextAfterCursor(1, 0);
+        if (!TextUtils.isEmpty(textAfter)) {
+            mStatistics.setIsEmptyUponStarting(false);
+            return;
+        }
+        if (textBefore != null && textAfter != null) {
+            mStatistics.setIsEmptyUponStarting(true);
+        }
+    }
+
+    private void start() {
+        maybeShowSplashScreen();
+        updateSuspendedState();
+        requestIndicatorRedraw();
+        mStatistics.reset();
+        checkForEmptyEditor();
+        if (!isAllowedToLog()) {
+            // Log.w(TAG, "not in usability mode; not logging");
+            return;
+        }
+        if (mFilesDir == null || !mFilesDir.exists()) {
+            Log.w(TAG, "IME storage directory does not exist.  Cannot start logging.");
+            return;
+        }
+        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();
+        commitCurrentLogUnit();
+
+        if (mMainLogBuffer != null) {
+            publishLogBuffer(mMainLogBuffer, mMainResearchLog, false /* isIncludingPrivateData */);
+            mMainResearchLog.close();
+            mMainLogBuffer = null;
+        }
+        if (mFeedbackLogBuffer != null) {
+            mFeedbackLog.close();
+            mFeedbackLogBuffer = null;
+        }
+    }
+
+    public boolean abort() {
+        boolean didAbortMainLog = false;
+        if (mMainLogBuffer != null) {
+            mMainLogBuffer.clear();
+            try {
+                didAbortMainLog = mMainResearchLog.blockingAbort();
+            } catch (InterruptedException e) {
+                // Don't know whether this succeeded or not.  We assume not; this is reported
+                // to the caller.
+            }
+            mMainLogBuffer = null;
+        }
+        boolean didAbortFeedbackLog = false;
+        if (mFeedbackLogBuffer != null) {
+            mFeedbackLogBuffer.clear();
+            try {
+                didAbortFeedbackLog = mFeedbackLog.blockingAbort();
+            } catch (InterruptedException e) {
+                // Don't know whether this succeeded or not.  We assume not; this is reported
+                // to the caller.
+            }
+            mFeedbackLogBuffer = null;
+        }
+        return didAbortMainLog && didAbortFeedbackLog;
     }
 
     private void restart() {
@@ -387,6 +436,8 @@
             abort();
         }
         requestIndicatorRedraw();
+        mPrefs = prefs;
+        prefsChanged(prefs);
     }
 
     public void presentResearchDialog(final LatinIME latinIME) {
@@ -450,79 +501,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 = new ArrayList<LogUnit>(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 = new ArrayList<LogUnit>();
+        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) {
@@ -530,7 +546,7 @@
     }
 
     private boolean isAllowedToLog() {
-        return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging;
+        return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog;
     }
 
     public void requestIndicatorRedraw() {
@@ -577,13 +593,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.
      *
@@ -599,10 +610,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.
      *
@@ -618,139 +633,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();
-    }
-
-    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 = new ArrayList<String[]>();
-        private final List<Object[]> mValuesList = new ArrayList<Object[]>();
-        private final List<Boolean> mIsPotentiallyPrivate = new ArrayList<Boolean>();
-
-        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) {
@@ -803,12 +733,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"
@@ -816,9 +740,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;
@@ -846,32 +767,15 @@
         stop();
     }
 
-    private static final String[] EVENTKEYS_LATINIME_COMMITTEXT = {
-        "LatinIMECommitText", "typedWord"
+    private static final String[] EVENTKEYS_PREFS_CHANGED = {
+        "PrefsChanged", "prefs"
     };
-
-    public static void latinIME_commitText(final CharSequence typedWord) {
-        final String scrubbedWord = scrubDigitsFromString(typedWord.toString());
-        final Object[] values = {
-            scrubbedWord
-        };
+    public static void prefsChanged(final SharedPreferences prefs) {
         final ResearchLogger researchLogger = getInstance();
-        researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_COMMITTEXT, values);
-        researchLogger.onWordComplete(scrubbedWord);
-    }
-
-    private static final String[] EVENTKEYS_USER_FEEDBACK = {
-        "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();
         final Object[] values = {
-            feedbackContents
+            prefs
         };
-        logUnit.addLogAtom(EVENTKEYS_USER_FEEDBACK, values, false);
-        researchLog.publishAllEvents(logUnit);
+        researchLogger.enqueueEvent(EVENTKEYS_PREFS_CHANGED, values);
     }
 
     // Regular logging methods
@@ -908,51 +812,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
         };
-        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONCODEINPUT, values);
-    }
-
-    private static final String[] EVENTKEYS_CORRECTION = {
-        "LogCorrection", "subgroup", "before", "after", "position"
-    };
-    public static void logCorrection(final String subgroup, final String before, final String after,
-            final int position) {
-        final Object[] values = {
-            subgroup, scrubDigitsFromString(before), scrubDigitsFromString(after), position
-        };
-        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_CORRECTION, values);
-    }
-
-    private static final String[] EVENTKEYS_LATINIME_COMMITCURRENTAUTOCORRECTION = {
-        "LatinIMECommitCurrentAutoCorrection", "typedWord", "autoCorrection"
-    };
-    public static void latinIME_commitCurrentAutoCorrection(final String typedWord,
-            final String autoCorrection) {
-        final Object[] values = {
-            scrubDigitsFromString(typedWord), scrubDigitsFromString(autoCorrection)
-        };
-        final ResearchLogger researchLogger = getInstance();
-        researchLogger.enqueuePotentiallyPrivateEvent(
-                EVENTKEYS_LATINIME_COMMITCURRENTAUTOCORRECTION, values);
-    }
-
-    private static final String[] EVENTKEYS_LATINIME_DELETESURROUNDINGTEXT = {
-        "LatinIMEDeleteSurroundingText", "length"
-    };
-    public static void latinIME_deleteSurroundingText(final int length) {
-        final Object[] values = {
-            length
-        };
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_DELETESURROUNDINGTEXT, values);
-    }
-
-    private static final String[] EVENTKEYS_LATINIME_DOUBLESPACEAUTOPERIOD = {
-        "LatinIMEDoubleSpaceAutoPeriod"
-    };
-    public static void latinIME_doubleSpaceAutoPeriod() {
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_DOUBLESPACEAUTOPERIOD, EVENTKEYS_NULLVALUES);
+        researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONCODEINPUT, values);
+        if (Character.isDigit(code)) {
+            researchLogger.setCurrentLogUnitContainsDigitFlag();
+        }
+        researchLogger.mStatistics.recordChar(code, time);
     }
 
     private static final String[] EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS = {
@@ -979,6 +848,10 @@
     public static void latinIME_onWindowHidden(final int savedSelectionStart,
             final int savedSelectionEnd, final InputConnection ic) {
         if (ic != null) {
+            // Capture the TextView contents.  This will trigger onUpdateSelection(), so we
+            // set sLatinIMEExpectingUpdateSelection so that when onUpdateSelection() is called,
+            // it can tell that it was generated by the logging code, and not by the user, and
+            // therefore keep user-visible state as is.
             ic.beginBatchEdit();
             ic.performContextMenuAction(android.R.id.selectAll);
             CharSequence charSequence = ic.getSelectedText(0);
@@ -1013,9 +886,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();
         }
     }
@@ -1048,29 +919,6 @@
         researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONUPDATESELECTION, values);
     }
 
-    private static final String[] EVENTKEYS_LATINIME_PERFORMEDITORACTION = {
-        "LatinIMEPerformEditorAction", "imeActionNext"
-    };
-    public static void latinIME_performEditorAction(final int imeActionNext) {
-        final Object[] values = {
-            imeActionNext
-        };
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_PERFORMEDITORACTION, values);
-    }
-
-    private static final String[] EVENTKEYS_LATINIME_PICKAPPLICATIONSPECIFIEDCOMPLETION = {
-        "LatinIMEPickApplicationSpecifiedCompletion", "index", "text", "x", "y"
-    };
-    public static void latinIME_pickApplicationSpecifiedCompletion(final int index,
-            final CharSequence cs, int x, int y) {
-        final Object[] values = {
-            index, cs, x, y
-        };
-        final ResearchLogger researchLogger = getInstance();
-        researchLogger.enqueuePotentiallyPrivateEvent(
-                EVENTKEYS_LATINIME_PICKAPPLICATIONSPECIFIEDCOMPLETION, values);
-    }
-
     private static final String[] EVENTKEYS_LATINIME_PICKSUGGESTIONMANUALLY = {
         "LatinIMEPickSuggestionManually", "replacedWord", "index", "suggestion", "x", "y"
     };
@@ -1096,21 +944,6 @@
         getInstance().enqueueEvent(EVENTKEYS_LATINIME_PUNCTUATIONSUGGESTION, values);
     }
 
-    private static final String[] EVENTKEYS_LATINIME_REVERTDOUBLESPACEWHILEINBATCHEDIT = {
-        "LatinIMERevertDoubleSpaceWhileInBatchEdit"
-    };
-    public static void latinIME_revertDoubleSpaceWhileInBatchEdit() {
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_REVERTDOUBLESPACEWHILEINBATCHEDIT,
-                EVENTKEYS_NULLVALUES);
-    }
-
-    private static final String[] EVENTKEYS_LATINIME_REVERTSWAPPUNCTUATION = {
-        "LatinIMERevertSwapPunctuation"
-    };
-    public static void latinIME_revertSwapPunctuation() {
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_REVERTSWAPPUNCTUATION, EVENTKEYS_NULLVALUES);
-    }
-
     private static final String[] EVENTKEYS_LATINIME_SENDKEYCODEPOINT = {
         "LatinIMESendKeyCodePoint", "code"
     };
@@ -1118,15 +951,18 @@
         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_SWAPSWAPPERANDSPACEWHILEINBATCHEDIT = {
-        "LatinIMESwapSwapperAndSpaceWhileInBatchEdit"
+    private static final String[] EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACE = {
+        "LatinIMESwapSwapperAndSpace"
     };
-    public static void latinIME_swapSwapperAndSpaceWhileInBatchEdit() {
-        getInstance().enqueueEvent(EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACEWHILEINBATCHEDIT,
-                EVENTKEYS_NULLVALUES);
+    public static void latinIME_swapSwapperAndSpace() {
+        getInstance().enqueueEvent(EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACE, EVENTKEYS_NULLVALUES);
     }
 
     private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_ONLONGPRESS = {
@@ -1245,6 +1081,128 @@
         getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_POINTERTRACKER_ONMOVEEVENT, values);
     }
 
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITCOMPLETION = {
+        "RichInputConnectionCommitCompletion", "completionInfo"
+    };
+    public static void richInputConnection_commitCompletion(final CompletionInfo completionInfo) {
+        final Object[] values = {
+            completionInfo
+        };
+        final ResearchLogger researchLogger = getInstance();
+        researchLogger.enqueuePotentiallyPrivateEvent(
+                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", "typedWord", "autoCorrection"
+    };
+    */
+    public static void richInputConnection_commitCorrection(CorrectionInfo correctionInfo) {
+        /*
+        final String typedWord = correctionInfo.getOldText().toString();
+        final String autoCorrection = correctionInfo.getNewText().toString();
+        final Object[] values = {
+            scrubDigitsFromString(typedWord), scrubDigitsFromString(autoCorrection)
+        };
+        final ResearchLogger researchLogger = getInstance();
+        researchLogger.enqueuePotentiallyPrivateEvent(
+                EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION, values);
+        */
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT = {
+        "RichInputConnectionCommitText", "typedWord", "newCursorPosition"
+    };
+    public static void richInputConnection_commitText(final CharSequence typedWord,
+            final int newCursorPosition) {
+        final String scrubbedWord = scrubDigitsFromString(typedWord.toString());
+        final Object[] values = {
+            scrubbedWord, newCursorPosition
+        };
+        final ResearchLogger researchLogger = getInstance();
+        researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT,
+                values);
+        researchLogger.onWordComplete(scrubbedWord);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT = {
+        "RichInputConnectionDeleteSurroundingText", "beforeLength", "afterLength"
+    };
+    public static void richInputConnection_deleteSurroundingText(final int beforeLength,
+            final int afterLength) {
+        final Object[] values = {
+            beforeLength, afterLength
+        };
+        getInstance().enqueuePotentiallyPrivateEvent(
+                EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, values);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT = {
+        "RichInputConnectionFinishComposingText"
+    };
+    public static void richInputConnection_finishComposingText() {
+        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT,
+                EVENTKEYS_NULLVALUES);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_PERFORMEDITORACTION = {
+        "RichInputConnectionPerformEditorAction", "imeActionNext"
+    };
+    public static void richInputConnection_performEditorAction(final int imeActionNext) {
+        final Object[] values = {
+            imeActionNext
+        };
+        getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_PERFORMEDITORACTION, values);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT = {
+        "RichInputConnectionSendKeyEvent", "eventTime", "action", "code"
+    };
+    public static void richInputConnection_sendKeyEvent(final KeyEvent keyEvent) {
+        final Object[] values = {
+            keyEvent.getEventTime(),
+            keyEvent.getAction(),
+            keyEvent.getKeyCode()
+        };
+        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT,
+                values);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT = {
+        "RichInputConnectionSetComposingText", "text", "newCursorPosition"
+    };
+    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().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT,
+                values);
+    }
+
+    private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION = {
+        "RichInputConnectionSetSelection", "from", "to"
+    };
+    public static void richInputConnection_setSelection(final int from, final int to) {
+        final Object[] values = {
+            from, to
+        };
+        getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION,
+                values);
+    }
+
     private static final String[] EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT = {
         "SuddenJumpingTouchEventHandlerOnTouchEvent", "motionEvent"
     };
@@ -1277,4 +1235,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
new file mode 100644
index 0000000..eab465a
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/Statistics.java
@@ -0,0 +1,146 @@
+/*
+ * 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.keyboard.Keyboard;
+
+public class Statistics {
+    // Number of characters entered during a typing session
+    int mCharCount;
+    // Number of letter characters entered during a typing session
+    int mLetterCount;
+    // Number of number characters entered
+    int mNumberCount;
+    // Number of space characters entered
+    int mSpaceCount;
+    // Number of delete operations entered (taps on the backspace key)
+    int mDeleteKeyCount;
+    // Number of words entered during a session.
+    int mWordCount;
+    // Whether the text field was empty upon editing
+    boolean mIsEmptyUponStarting;
+    boolean mIsEmptinessStateKnown;
+
+    // Timers to count average time to enter a key, first press a delete key,
+    // between delete keys, and then to return typing after a delete key.
+    final AverageTimeCounter mKeyCounter = new AverageTimeCounter();
+    final AverageTimeCounter mBeforeDeleteKeyCounter = new AverageTimeCounter();
+    final AverageTimeCounter mDuringRepeatedDeleteKeysCounter = new AverageTimeCounter();
+    final AverageTimeCounter mAfterDeleteKeyCounter = new AverageTimeCounter();
+
+    static class AverageTimeCounter {
+        int mCount;
+        int mTotalTime;
+
+        public void reset() {
+            mCount = 0;
+            mTotalTime = 0;
+        }
+
+        public void add(long deltaTime) {
+            mCount++;
+            mTotalTime += deltaTime;
+        }
+
+        public int getAverageTime() {
+            if (mCount == 0) {
+                return 0;
+            }
+            return mTotalTime / mCount;
+        }
+    }
+
+    // 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 = 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;
+    // The type of the last keypress (delete key or not)
+    boolean mIsLastKeyDeleteKey;
+
+    private static final Statistics sInstance = new Statistics();
+
+    public static Statistics getInstance() {
+        return sInstance;
+    }
+
+    private Statistics() {
+        reset();
+    }
+
+    public void reset() {
+        mCharCount = 0;
+        mLetterCount = 0;
+        mNumberCount = 0;
+        mSpaceCount = 0;
+        mDeleteKeyCount = 0;
+        mWordCount = 0;
+        mIsEmptyUponStarting = true;
+        mIsEmptinessStateKnown = false;
+        mKeyCounter.reset();
+        mBeforeDeleteKeyCounter.reset();
+        mDuringRepeatedDeleteKeysCounter.reset();
+        mAfterDeleteKeyCounter.reset();
+
+        mLastTapTime = 0;
+        mIsLastKeyDeleteKey = false;
+    }
+
+    public void recordChar(int codePoint, long time) {
+        final long delta = time - mLastTapTime;
+        if (codePoint == Keyboard.CODE_DELETE) {
+            mDeleteKeyCount++;
+            if (delta < MIN_DELETION_INTERMISSION) {
+                if (mIsLastKeyDeleteKey) {
+                    mDuringRepeatedDeleteKeysCounter.add(delta);
+                } else {
+                    mBeforeDeleteKeyCounter.add(delta);
+                }
+            }
+            mIsLastKeyDeleteKey = true;
+        } else {
+            mCharCount++;
+            if (Character.isDigit(codePoint)) {
+                mNumberCount++;
+            }
+            if (Character.isLetter(codePoint)) {
+                mLetterCount++;
+            }
+            if (Character.isSpaceChar(codePoint)) {
+                mSpaceCount++;
+            }
+            if (mIsLastKeyDeleteKey && delta < MIN_DELETION_INTERMISSION) {
+                mAfterDeleteKeyCounter.add(delta);
+            } else if (!mIsLastKeyDeleteKey && delta < MIN_TYPING_INTERMISSION) {
+                mKeyCounter.add(delta);
+            }
+            mIsLastKeyDeleteKey = false;
+        }
+        mLastTapTime = time;
+    }
+
+    public void recordWordEntered() {
+        mWordCount++;
+    }
+
+    public void setIsEmptyUponStarting(final boolean isEmpty) {
+        mIsEmptyUponStarting = isEmpty;
+        mIsEmptinessStateKnown = true;
+    }
+}
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;
+    }
+}