diff --git a/java/res/values-pl/strings-appname.xml b/java/res/values-pl/strings-appname.xml
index 4d244d70556b9a9918a196e0e8c085c1eb79f856..e460644a331019a3aeec915e9af739696bd8686e 100644
--- a/java/res/values-pl/strings-appname.xml
+++ b/java/res/values-pl/strings-appname.xml
@@ -21,7 +21,10 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="english_ime_name" msgid="178705338187710493">"Klawiatura Android"</string>
-    <string name="spell_checker_service_name" msgid="6268342166872202903">"Sprawdzanie pisowni w Androidzie"</string>
-    <string name="english_ime_settings" msgid="7470027018752707691">"Ustawienia klawiatury Android"</string>
-    <string name="android_spell_checker_settings" msgid="8397842018475560441">"Ustawienia sprawdzania pisowni"</string>
+    <!-- no translation found for spell_checker_service_name (6268342166872202903) -->
+    <skip />
+    <!-- no translation found for english_ime_settings (7470027018752707691) -->
+    <skip />
+    <!-- no translation found for android_spell_checker_settings (8397842018475560441) -->
+    <skip />
 </resources>
diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml
index 03dce9ca63fa56f4bdbe0caa439d3330096dddbb..273525100669bb35f2ee8ae5758bdb93d2615545 100644
--- a/java/res/values/strings.xml
+++ b/java/res/values/strings.xml
@@ -308,6 +308,8 @@
       - operation[CHAR LIMIT=100] -->
     <!-- TODO: remove translatable=false attribute once text is stable -->
     <string name="research_feedback_demonstration_instructions" translatable="false">Please demonstrate the issue you are writing about.\n\nWhen finished, select the \"Bug?\" button again."</string>
+    <!-- Title of a preference to send feedback. [CHAR LIMIT=30]-->
+    <string name="send_feedback">Send feedback</string>
     <!-- Temporary notification of recording failure [CHAR LIMIT=100] -->
     <!-- TODO: remove translatable=false attribute once text is stable -->
     <string name="research_feedback_recording_failure" translatable="false">Recording cancelled due to timeout</string>
diff --git a/java/res/xml/prefs.xml b/java/res/xml/prefs.xml
index 51f580721a58b50b305883d82b56f1523f9a1fb3..e299ce4f8cd07679defc12f621cc0a06f62df3ce 100644
--- a/java/res/xml/prefs.xml
+++ b/java/res/xml/prefs.xml
@@ -176,6 +176,9 @@
                 android:key="pref_show_setup_wizard_icon"
                 android:title="@string/show_setup_wizard_icon" />
         </PreferenceScreen>
+        <PreferenceScreen
+            android:key="send_feedback"
+            android:title="@string/send_feedback" />
         <PreferenceScreen
             android:key="debug_settings"
             android:title="Debug settings"
diff --git a/java/src/com/android/inputmethod/latin/DebugSettings.java b/java/src/com/android/inputmethod/latin/DebugSettings.java
index 7df266ef2d60d9f75ff999c581bc853fe237d8fd..c2aade64d7181d2f20d2b4ba9e6ea339cebe8d8b 100644
--- a/java/src/com/android/inputmethod/latin/DebugSettings.java
+++ b/java/src/com/android/inputmethod/latin/DebugSettings.java
@@ -57,7 +57,7 @@ public final class DebugSettings extends PreferenceFragment
         if (usabilityStudyPref instanceof CheckBoxPreference) {
             final CheckBoxPreference checkbox = (CheckBoxPreference)usabilityStudyPref;
             checkbox.setChecked(prefs.getBoolean(PREF_USABILITY_STUDY_MODE,
-                    ResearchLogger.DEFAULT_USABILITY_STUDY_MODE));
+                    LatinImeLogger.getUsabilityStudyMode(prefs)));
             checkbox.setSummary(R.string.settings_warning_researcher_mode);
         }
         final Preference statisticsLoggingPref = findPreference(PREF_STATISTICS_LOGGING);
diff --git a/java/src/com/android/inputmethod/latin/FeedbackUtils.java b/java/src/com/android/inputmethod/latin/FeedbackUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..1e5260e34f73bbf08f0473f5db866ad6b9fc036d
--- /dev/null
+++ b/java/src/com/android/inputmethod/latin/FeedbackUtils.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.latin;
+
+import android.content.Context;
+
+public class FeedbackUtils {
+    public static boolean isFeedbackFormSupported() {
+        return false;
+    }
+
+    public static void showFeedbackForm(Context context) {
+    }
+}
diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java
index b724d2fa6a346ec38bdc0ac6f75710f937e2af71..e3650d9ccb84b7ae0fc5d49a275841cc42497fc2 100644
--- a/java/src/com/android/inputmethod/latin/LatinIME.java
+++ b/java/src/com/android/inputmethod/latin/LatinIME.java
@@ -430,7 +430,7 @@ public final class LatinIME extends InputMethodService implements KeyboardAction
         initSuggest();
 
         if (ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
-            ResearchLogger.getInstance().init(this, mKeyboardSwitcher);
+            ResearchLogger.getInstance().init(this, mKeyboardSwitcher, mSuggest);
         }
         mDisplayOrientation = getResources().getConfiguration().orientation;
 
@@ -565,6 +565,9 @@ public final class LatinIME extends InputMethodService implements KeyboardAction
         }
         mSettings.onDestroy();
         unregisterReceiver(mReceiver);
+        if (ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
+            ResearchLogger.getInstance().onDestroy();
+        }
         // TODO: The development-only-diagnostic version is not supported by the Dictionary Pack
         // Service yet.
         if (!ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
diff --git a/java/src/com/android/inputmethod/latin/LatinImeLogger.java b/java/src/com/android/inputmethod/latin/LatinImeLogger.java
index e4e8b94b2e2e68d289e56dde637a2801b5a431f6..3f2b0a3f41ea37e19cb8075dd3c5743c73a75060 100644
--- a/java/src/com/android/inputmethod/latin/LatinImeLogger.java
+++ b/java/src/com/android/inputmethod/latin/LatinImeLogger.java
@@ -37,6 +37,10 @@ public final class LatinImeLogger implements SharedPreferences.OnSharedPreferenc
     public static void commit() {
     }
 
+    public static boolean getUsabilityStudyMode(final SharedPreferences prefs) {
+        return false;
+    }
+
     public static void onDestroy() {
     }
 
diff --git a/java/src/com/android/inputmethod/latin/Settings.java b/java/src/com/android/inputmethod/latin/Settings.java
index 4cbfa8ea182f6deed7337edbddab9ceb9a965b54..ce659bf45a4e37e244558c040b34d215d562408e 100644
--- a/java/src/com/android/inputmethod/latin/Settings.java
+++ b/java/src/com/android/inputmethod/latin/Settings.java
@@ -77,6 +77,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
     private static final String PREF_SUPPRESS_LANGUAGE_SWITCH_KEY =
             "pref_suppress_language_switch_key";
 
+    public static final String PREF_SEND_FEEDBACK = "send_feedback";
+
     private Resources mRes;
     private SharedPreferences mPrefs;
     private Locale mCurrentLocale;
diff --git a/java/src/com/android/inputmethod/latin/SettingsFragment.java b/java/src/com/android/inputmethod/latin/SettingsFragment.java
index fa17b4ffcf8dbaa1dc459dfb04fed97ff8cc4bad..4fdd839112a5b2792ee8ae9d79bfdc0b0819241d 100644
--- a/java/src/com/android/inputmethod/latin/SettingsFragment.java
+++ b/java/src/com/android/inputmethod/latin/SettingsFragment.java
@@ -16,6 +16,7 @@
 
 package com.android.inputmethod.latin;
 
+import android.app.Activity;
 import android.app.backup.BackupManager;
 import android.content.Context;
 import android.content.Intent;
@@ -26,6 +27,7 @@ import android.os.Bundle;
 import android.preference.CheckBoxPreference;
 import android.preference.ListPreference;
 import android.preference.Preference;
+import android.preference.Preference.OnPreferenceClickListener;
 import android.preference.PreferenceGroup;
 import android.preference.PreferenceScreen;
 import android.view.inputmethod.InputMethodSubtype;
@@ -103,6 +105,25 @@ public final class SettingsFragment extends InputMethodSettingsFragment
             }
         }
 
+        final Preference feedbackSettings = findPreference(Settings.PREF_SEND_FEEDBACK);
+        if (feedbackSettings != null) {
+            if (FeedbackUtils.isFeedbackFormSupported()) {
+                feedbackSettings.setOnPreferenceClickListener(new OnPreferenceClickListener() {
+                    @Override
+                    public boolean onPreferenceClick(Preference arg0) {
+                        final Activity activity = getActivity();
+                        FeedbackUtils.showFeedbackForm(activity);
+                        if (!activity.isFinishing()) {
+                            activity.finish();
+                        }
+                        return true;
+                    }
+                });
+            } else {
+                miscSettings.removePreference(feedbackSettings);
+            }
+        }
+
         final boolean showVoiceKeyOption = res.getBoolean(
                 R.bool.config_enable_show_voice_key_option);
         if (!showVoiceKeyOption) {
diff --git a/java/src/com/android/inputmethod/latin/Utils.java b/java/src/com/android/inputmethod/latin/Utils.java
index acfcd5354d8ae7cce985cf02d589199ce4a03b17..7a604dc6a65487c030f053fb08c445ca489ad6d1 100644
--- a/java/src/com/android/inputmethod/latin/Utils.java
+++ b/java/src/com/android/inputmethod/latin/Utils.java
@@ -28,6 +28,7 @@ import android.os.Process;
 import android.text.TextUtils;
 import android.util.Log;
 
+import com.android.inputmethod.annotations.UsedForTesting;
 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
 
 import java.io.BufferedReader;
@@ -77,6 +78,7 @@ public final class Utils {
         private RingCharBuffer() {
             // Intentional empty constructor for singleton.
         }
+        @UsedForTesting
         public static RingCharBuffer getInstance() {
             return sRingCharBuffer;
         }
@@ -93,6 +95,7 @@ public final class Utils {
             return ret < 0 ? ret + BUFSIZE : ret;
         }
         // TODO: accept code points
+        @UsedForTesting
         public void push(char c, int x, int y) {
             if (!mEnabled) return;
             mCharBuf[mEnd] = c;
diff --git a/java/src/com/android/inputmethod/latin/define/ProductionFlag.java b/java/src/com/android/inputmethod/latin/define/ProductionFlag.java
index dc937fb2508a210dd67aec9e6209bcf6c3db8ae6..699e47b6a333f546b18fbfa75a3e7866a1f09147 100644
--- a/java/src/com/android/inputmethod/latin/define/ProductionFlag.java
+++ b/java/src/com/android/inputmethod/latin/define/ProductionFlag.java
@@ -28,5 +28,5 @@ public final class ProductionFlag {
     // USES_DEVELOPMENT_ONLY_DIAGNOSTICS must be false for any production build.
     public static final boolean USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG = false;
 
-    public static final boolean IS_HARDWARE_KEYBOARD_SUPPORTED = false;
+    public static final boolean IS_HARDWARE_KEYBOARD_SUPPORTED = true;
 }
diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java
index 904110c462ac0ae9712293c61aaeb79f1ed99e38..1c01675bdd58f13f8e898b2e97933d0b6ab00284 100644
--- a/java/src/com/android/inputmethod/research/LogUnit.java
+++ b/java/src/com/android/inputmethod/research/LogUnit.java
@@ -16,7 +16,6 @@
 
 package com.android.inputmethod.research;
 
-import android.content.SharedPreferences;
 import android.os.SystemClock;
 import android.text.TextUtils;
 import android.util.JsonWriter;
@@ -45,7 +44,7 @@ import java.util.List;
  * 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 {
+public class LogUnit {
     private static final String TAG = LogUnit.class.getSimpleName();
     private static final boolean DEBUG = false
             && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
@@ -121,22 +120,6 @@ import java.util.List;
      */
     public synchronized void publishTo(final ResearchLog researchLog,
             final boolean canIncludePrivateData) {
-        // Prepare debugging output if necessary
-        final StringWriter debugStringWriter;
-        final JsonWriter debugJsonWriter;
-        if (DEBUG) {
-            debugStringWriter = new StringWriter();
-            debugJsonWriter = new JsonWriter(debugStringWriter);
-            debugJsonWriter.setIndent("  ");
-            try {
-                debugJsonWriter.beginArray();
-            } catch (IOException e) {
-                Log.e(TAG, "Could not open array in JsonWriter", e);
-            }
-        } else {
-            debugStringWriter = null;
-            debugJsonWriter = null;
-        }
         // Write out any logStatement that passes the privacy filter.
         final int size = mLogStatementList.size();
         if (size != 0) {
@@ -159,29 +142,12 @@ import java.util.List;
                     outputLogUnitStart(jsonWriter, canIncludePrivateData);
                 }
                 logStatement.outputToLocked(jsonWriter, mTimeList.get(i), mValuesList.get(i));
-                if (DEBUG) {
-                    logStatement.outputToLocked(debugJsonWriter, mTimeList.get(i),
-                            mValuesList.get(i));
-                }
             }
             if (jsonWriter != null) {
                 // We must have called logUnitStart earlier, so emit a logUnitStop.
                 outputLogUnitStop(jsonWriter);
             }
         }
-        if (DEBUG) {
-            try {
-                debugJsonWriter.endArray();
-                debugJsonWriter.flush();
-            } catch (IOException e) {
-                Log.e(TAG, "Could not close array in JsonWriter", e);
-            }
-            final String bigString = debugStringWriter.getBuffer().toString();
-            final String[] lines = bigString.split("\n");
-            for (String line : lines) {
-                Log.d(TAG, line);
-            }
-        }
     }
 
     private static final String WORD_KEY = "_wo";
diff --git a/java/src/com/android/inputmethod/research/MainLogBuffer.java b/java/src/com/android/inputmethod/research/MainLogBuffer.java
index eadc886b5c7df91e1d638787a0852f687594bcc3..3303d2bdb10ad719cba2a315c9ea146f770c2a48 100644
--- a/java/src/com/android/inputmethod/research/MainLogBuffer.java
+++ b/java/src/com/android/inputmethod/research/MainLogBuffer.java
@@ -18,6 +18,7 @@ package com.android.inputmethod.research;
 
 import android.util.Log;
 
+import com.android.inputmethod.annotations.UsedForTesting;
 import com.android.inputmethod.latin.Dictionary;
 import com.android.inputmethod.latin.Suggest;
 import com.android.inputmethod.latin.define.ProductionFlag;
@@ -65,7 +66,11 @@ public abstract class MainLogBuffer extends FixedLogBuffer {
     // The size of the n-grams logged.  E.g. N_GRAM_SIZE = 2 means to sample bigrams.
     public static final int N_GRAM_SIZE = 2;
 
-    private Suggest mSuggest;
+    // TODO: Remove dependence on Suggest, and pass in Dictionary as a parameter to an appropriate
+    // method.
+    private final Suggest mSuggest;
+    @UsedForTesting
+    private Dictionary mDictionaryForTesting;
     private boolean mIsStopping = false;
 
     /* package for test */ int mNumWordsBetweenNGrams;
@@ -74,17 +79,23 @@ public abstract class MainLogBuffer extends FixedLogBuffer {
     // after a sample is taken.
     /* package for test */ int mNumWordsUntilSafeToSample;
 
-    public MainLogBuffer(final int wordsBetweenSamples, final int numInitialWordsToIgnore) {
+    public MainLogBuffer(final int wordsBetweenSamples, final int numInitialWordsToIgnore,
+            final Suggest suggest) {
         super(N_GRAM_SIZE + wordsBetweenSamples);
         mNumWordsBetweenNGrams = wordsBetweenSamples;
         mNumWordsUntilSafeToSample = DEBUG ? 0 : numInitialWordsToIgnore;
+        mSuggest = suggest;
     }
 
-    public void setSuggest(final Suggest suggest) {
-        mSuggest = suggest;
+    @UsedForTesting
+    /* package for test */ void setDictionaryForTesting(final Dictionary dictionary) {
+        mDictionaryForTesting = dictionary;
     }
 
     private Dictionary getDictionary() {
+        if (mDictionaryForTesting != null) {
+            return mDictionaryForTesting;
+        }
         if (mSuggest == null || !mSuggest.hasMainDictionary()) return null;
         return mSuggest.getMainDictionary();
     }
diff --git a/java/src/com/android/inputmethod/research/ResearchLog.java b/java/src/com/android/inputmethod/research/ResearchLog.java
index d1fdc6024850bed9e427090ea5d1b8c463714ca3..35a491f2c7e8e79832c76bee045ba345d8419131 100644
--- a/java/src/com/android/inputmethod/research/ResearchLog.java
+++ b/java/src/com/android/inputmethod/research/ResearchLog.java
@@ -20,11 +20,11 @@ import android.content.Context;
 import android.util.JsonWriter;
 import android.util.Log;
 
+import com.android.inputmethod.annotations.UsedForTesting;
 import com.android.inputmethod.latin.define.ProductionFlag;
 
 import java.io.BufferedWriter;
 import java.io.File;
-import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
@@ -38,18 +38,24 @@ import java.util.concurrent.TimeUnit;
 /**
  * Logs the use of the LatinIME keyboard.
  *
- * This class logs operations on the IME keyboard, including what the user has typed.
- * Data is stored locally in a file in app-specific storage.
+ * This class logs operations on the IME keyboard, including what the user has typed.  Data is
+ * written to a {@link JsonWriter}, which will write to a local file.
+ *
+ * The JsonWriter is created on-demand by calling {@link #getInitializedJsonWriterLocked}.
+ *
+ * This class uses an executor to perform file-writing operations on a separate thread.  It also
+ * tries to avoid creating unnecessary files if there is nothing to write.  It also handles
+ * flushing, making sure it happens, but not too frequently.
  *
  * This functionality is off by default. See
  * {@link ProductionFlag#USES_DEVELOPMENT_ONLY_DIAGNOSTICS}.
  */
 public class ResearchLog {
+    // TODO: Automatically initialize the JsonWriter rather than requiring the caller to manage it.
     private static final String TAG = ResearchLog.class.getSimpleName();
     private static final boolean DEBUG = false
             && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
     private static final long FLUSH_DELAY_IN_MS = 1000 * 5;
-    private static final int ABORT_TIMEOUT_IN_MS = 1000 * 4;
 
     /* package */ final ScheduledExecutorService mExecutor;
     /* package */ final File mFile;
@@ -89,28 +95,33 @@ public class ResearchLog {
         mContext = context;
     }
 
-    public synchronized void close(final Runnable onClosed) {
+    /**
+     * Waits for any publication requests to finish and closes the {@link JsonWriter} used for
+     * output.
+     *
+     * See class comment for details about {@code JsonWriter} construction.
+     *
+     * @param onClosed run after the close() operation has completed asynchronously
+     */
+    private synchronized void close(final Runnable onClosed) {
         mExecutor.submit(new Callable<Object>() {
             @Override
             public Object call() throws Exception {
                 try {
                     if (mHasWrittenData) {
                         mJsonWriter.endArray();
-                        mJsonWriter.flush();
-                        mJsonWriter.close();
-                        if (DEBUG) {
-                            Log.d(TAG, "wrote log to " + mFile);
-                        }
                         mHasWrittenData = false;
-                    } else {
-                        if (DEBUG) {
-                            Log.d(TAG, "close() called, but no data, not outputting");
-                        }
+                    }
+                    mJsonWriter.flush();
+                    mJsonWriter.close();
+                    if (DEBUG) {
+                        Log.d(TAG, "wrote log to " + mFile);
                     }
                 } catch (Exception e) {
-                    Log.d(TAG, "error when closing ResearchLog:");
-                    e.printStackTrace();
+                    Log.d(TAG, "error when closing ResearchLog:", e);
                 } finally {
+                    // Marking the file as read-only signals that this log file is ready to be
+                    // uploaded.
                     if (mFile != null && mFile.exists()) {
                         mFile.setWritable(false, false);
                     }
@@ -125,9 +136,24 @@ public class ResearchLog {
         mExecutor.shutdown();
     }
 
-    private boolean mIsAbortSuccessful;
+    /**
+     * Block until the research log has shut down and spooled out all output or {@code timeout}
+     * occurs.
+     *
+     * @param timeout time to wait for close in milliseconds
+     */
+    public void blockingClose(final long timeout) {
+        close(null);
+        awaitTermination(timeout, TimeUnit.MILLISECONDS);
+    }
 
-    public synchronized void abort() {
+    /**
+     * Waits for publication requests to finish, closes the JsonWriter, but then deletes the backing
+     * output file.
+     *
+     * @param onAbort run after the abort() operation has completed asynchronously
+     */
+    private synchronized void abort(final Runnable onAbort) {
         mExecutor.submit(new Callable<Object>() {
             @Override
             public Object call() throws Exception {
@@ -139,7 +165,10 @@ public class ResearchLog {
                     }
                 } finally {
                     if (mFile != null) {
-                        mIsAbortSuccessful = mFile.delete();
+                        mFile.delete();
+                    }
+                    if (onAbort != null) {
+                        onAbort.run();
                     }
                 }
                 return null;
@@ -149,14 +178,25 @@ public class ResearchLog {
         mExecutor.shutdown();
     }
 
-    public boolean blockingAbort() throws InterruptedException {
-        abort();
-        mExecutor.awaitTermination(ABORT_TIMEOUT_IN_MS, TimeUnit.MILLISECONDS);
-        return mIsAbortSuccessful;
+    /**
+     * Block until the research log has aborted or {@code timeout} occurs.
+     *
+     * @param timeout time to wait for close in milliseconds
+     */
+    public void blockingAbort(final long timeout) {
+        abort(null);
+        awaitTermination(timeout, TimeUnit.MILLISECONDS);
     }
 
-    public void awaitTermination(int delay, TimeUnit timeUnit) throws InterruptedException {
-        mExecutor.awaitTermination(delay, timeUnit);
+    @UsedForTesting
+    public void awaitTermination(final long delay, final TimeUnit timeUnit) {
+        try {
+            if (!mExecutor.awaitTermination(delay, timeUnit)) {
+                Log.e(TAG, "ResearchLog executor timed out while awaiting terminaion");
+            }
+        } catch (final InterruptedException e) {
+            Log.e(TAG, "ResearchLog executor interrupted while awaiting terminaion", e);
+        }
     }
 
     /* package */ synchronized void flush() {
@@ -186,6 +226,12 @@ public class ResearchLog {
         mFlushFuture = mExecutor.schedule(mFlushCallable, FLUSH_DELAY_IN_MS, TimeUnit.MILLISECONDS);
     }
 
+    /**
+     * Queues up {@code logUnit} to be published in the background.
+     *
+     * @param logUnit the {@link LogUnit} to be published
+     * @param canIncludePrivateData whether private data in the LogUnit should be included
+     */
     public synchronized void publish(final LogUnit logUnit, final boolean canIncludePrivateData) {
         try {
             mExecutor.submit(new Callable<Object>() {
@@ -196,10 +242,10 @@ public class ResearchLog {
                     return null;
                 }
             });
-        } catch (RejectedExecutionException e) {
+        } catch (final RejectedExecutionException e) {
             // TODO: Add code to record loss of data, and report.
             if (DEBUG) {
-                Log.d(TAG, "ResearchLog.publish() rejecting scheduled execution");
+                Log.d(TAG, "ResearchLog.publish() rejecting scheduled execution", e);
             }
         }
     }
diff --git a/java/src/com/android/inputmethod/research/ResearchLogDirectory.java b/java/src/com/android/inputmethod/research/ResearchLogDirectory.java
new file mode 100644
index 0000000000000000000000000000000000000000..291dea5d0a473b943713b0e1a779b109685c6e52
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/ResearchLogDirectory.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.research;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileFilter;
+
+/**
+ * Manages log files.
+ *
+ * This class handles all aspects where and how research log data is stored.  This includes
+ * generating log filenames in the correct place with the correct names, and cleaning up log files
+ * under this directory.
+ */
+public class ResearchLogDirectory {
+    public static final String TAG = ResearchLogDirectory.class.getSimpleName();
+    /* package */ static final String LOG_FILENAME_PREFIX = "researchLog";
+    private static final String FILENAME_SUFFIX = ".txt";
+    private static final String USER_RECORDING_FILENAME_PREFIX = "recording";
+
+    private static final ReadOnlyLogFileFilter sUploadableLogFileFilter =
+            new ReadOnlyLogFileFilter();
+
+    private final File mFilesDir;
+
+    static class ReadOnlyLogFileFilter implements FileFilter {
+        @Override
+        public boolean accept(final File pathname) {
+            return pathname.getName().startsWith(ResearchLogDirectory.LOG_FILENAME_PREFIX)
+                    && !pathname.canWrite();
+        }
+    }
+
+    /**
+     * Creates a new ResearchLogDirectory, creating the storage directory if it does not exist.
+     */
+    public ResearchLogDirectory(final Context context) {
+        mFilesDir = getLoggingDirectory(context);
+        if (mFilesDir == null) {
+            throw new NullPointerException("No files directory specified");
+        }
+        if (!mFilesDir.exists()) {
+            mFilesDir.mkdirs();
+        }
+    }
+
+    private File getLoggingDirectory(final Context context) {
+        // TODO: Switch to using a subdirectory of getFilesDir().
+        return context.getFilesDir();
+    }
+
+    /**
+     * Get an array of log files that are ready for uploading.
+     *
+     * A file is ready for uploading if it is marked as read-only.
+     *
+     * @return the array of uploadable files
+     */
+    public File[] getUploadableLogFiles() {
+        try {
+            return mFilesDir.listFiles(sUploadableLogFileFilter);
+        } catch (final SecurityException e) {
+            Log.e(TAG, "Could not cleanup log directory, permission denied", e);
+            return new File[0];
+        }
+    }
+
+    public void cleanupLogFilesOlderThan(final long time) {
+        try {
+            for (final File file : mFilesDir.listFiles()) {
+                final String filename = file.getName();
+                if ((filename.startsWith(LOG_FILENAME_PREFIX)
+                        || filename.startsWith(USER_RECORDING_FILENAME_PREFIX))
+                        && (file.lastModified() < time)) {
+                    file.delete();
+                }
+            }
+        } catch (final SecurityException e) {
+            Log.e(TAG, "Could not cleanup log directory, permission denied", e);
+        }
+    }
+
+    public File getLogFilePath(final long time) {
+        return new File(mFilesDir, getUniqueFilename(LOG_FILENAME_PREFIX, time));
+    }
+
+    public File getUserRecordingFilePath(final long time) {
+        return new File(mFilesDir, getUniqueFilename(USER_RECORDING_FILENAME_PREFIX, time));
+    }
+
+    private static String getUniqueFilename(final String prefix, final long time) {
+        return prefix + "-" + time + FILENAME_SUFFIX;
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java
index 0d6dbfff615d135cf39db8a5487eea6956cd6496..a38a226f0b425a766076cce3c83e4c64c18015a6 100644
--- a/java/src/com/android/inputmethod/research/ResearchLogger.java
+++ b/java/src/com/android/inputmethod/research/ResearchLogger.java
@@ -124,17 +124,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     // field holds a channel name, the developer does not have to re-enter it when using the
     // feedback mechanism to generate multiple tests.
     private static final boolean FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD = false;
-    public static final boolean DEFAULT_USABILITY_STUDY_MODE = false;
     /* package */ static boolean sIsLogging = false;
     private static final int OUTPUT_FORMAT_VERSION = 5;
     private static final String PREF_USABILITY_STUDY_MODE = "usability_study_mode";
-    private static final String PREF_RESEARCH_HAS_SEEN_SPLASH = "pref_research_has_seen_splash";
-    /* package */ static final String LOG_FILENAME_PREFIX = "researchLog";
-    private static final String LOG_FILENAME_SUFFIX = ".txt";
-    /* package */ static final String USER_RECORDING_FILENAME_PREFIX = "recording";
-    private static final String USER_RECORDING_FILENAME_SUFFIX = ".txt";
-    private static final SimpleDateFormat TIMESTAMP_DATEFORMAT =
-            new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
     // Whether all words should be recorded, leaving unsampled word between bigrams.  Useful for
     // testing.
     /* package for test */ static final boolean IS_LOGGING_EVERYTHING = false
@@ -156,15 +148,16 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     // 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 String PREF_RESEARCH_SAVED_CHANNEL = "pref_research_saved_channel";
 
+    private static final long RESEARCHLOG_CLOSE_TIMEOUT_IN_MS = 5 * 1000;
+    private static final long RESEARCHLOG_ABORT_TIMEOUT_IN_MS = 5 * 1000;
+    private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS;
+    private static final long MAX_LOGFILE_AGE_IN_MS = 4 * DateUtils.DAY_IN_MILLIS;
+
     private static final ResearchLogger sInstance = new ResearchLogger();
     private static String sAccountType = null;
     private static String sAllowedAccountDomain = null;
-    // to write to a different filename, e.g., for testing, set mFile before calling start()
-    /* package */ File mFilesDir;
-    /* package */ String mUUIDString;
     /* package */ ResearchLog mMainResearchLog;
     // mFeedbackLog records all events for the session, private or not (excepting
     // passwords).  It is written to permanent storage only if the user explicitly commands
@@ -190,9 +183,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             Character.codePointAt("\uE000", 0);  // U+E000 is in the "private-use area"
     // U+E001 is in the "private-use area"
     /* package for test */ static final String WORD_REPLACEMENT_STRING = "\uE001";
-    private static final String PREF_LAST_CLEANUP_TIME = "pref_last_cleanup_time";
-    private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS;
-    private static final long MAX_LOGFILE_AGE_IN_MS = 4 * DateUtils.DAY_IN_MILLIS;
     protected static final int SUSPEND_DURATION_IN_MINUTES = 1;
     // set when LatinIME should ignore an onUpdateSelection() callback that
     // arises from operations in this class
@@ -206,11 +196,12 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     private final Statistics mStatistics;
     private final MotionEventReader mMotionEventReader = new MotionEventReader();
     private final Replayer mReplayer = Replayer.getInstance();
+    private ResearchLogDirectory mResearchLogDirectory;
 
     private Intent mUploadIntent;
     private Intent mUploadNowIntent;
 
-    private LogUnit mCurrentLogUnit = new LogUnit();
+    /* package for test */ LogUnit mCurrentLogUnit = new LogUnit();
 
     // Gestured or tapped words may be committed after the gesture of the next word has started.
     // To ensure that the gesture data of the next word is not associated with the previous word,
@@ -239,50 +230,42 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         return sInstance;
     }
 
-    public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) {
+    public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher,
+            final Suggest suggest) {
         assert latinIME != null;
-        if (latinIME == null) {
-            Log.w(TAG, "IMS is null; logging is off");
-        } else {
-            mFilesDir = latinIME.getFilesDir();
-            if (mFilesDir == null || !mFilesDir.exists()) {
-                Log.w(TAG, "IME storage directory does not exist.");
-            }
-        }
-        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(latinIME);
-        if (prefs != null) {
-            mUUIDString = getUUID(prefs);
-            if (!prefs.contains(PREF_USABILITY_STUDY_MODE)) {
-                Editor e = prefs.edit();
-                e.putBoolean(PREF_USABILITY_STUDY_MODE, DEFAULT_USABILITY_STUDY_MODE);
-                e.apply();
-            }
-            sIsLogging = prefs.getBoolean(PREF_USABILITY_STUDY_MODE, false);
-            prefs.registerOnSharedPreferenceChangeListener(this);
-
-            final long lastCleanupTime = prefs.getLong(PREF_LAST_CLEANUP_TIME, 0L);
-            final long now = System.currentTimeMillis();
-            if (lastCleanupTime + DURATION_BETWEEN_DIR_CLEANUP_IN_MS < now) {
-                final long timeHorizon = now - MAX_LOGFILE_AGE_IN_MS;
-                cleanupLoggingDir(mFilesDir, timeHorizon);
-                Editor e = prefs.edit();
-                e.putLong(PREF_LAST_CLEANUP_TIME, now);
-                e.apply();
-            }
-        }
+        mLatinIME = latinIME;
+        mPrefs = PreferenceManager.getDefaultSharedPreferences(latinIME);
+        mPrefs.registerOnSharedPreferenceChangeListener(this);
+
+        // Initialize fields from preferences
+        sIsLogging = ResearchSettings.readResearchLoggerEnabledFlag(mPrefs);
+
+        // Initialize fields from resources
         final Resources res = latinIME.getResources();
         sAccountType = res.getString(R.string.research_account_type);
         sAllowedAccountDomain = res.getString(R.string.research_allowed_account_domain);
-        mLatinIME = latinIME;
-        mPrefs = prefs;
+
+        // Initialize directory manager
+        mResearchLogDirectory = new ResearchLogDirectory(mLatinIME);
+        cleanLogDirectoryIfNeeded(mResearchLogDirectory, System.currentTimeMillis());
+
+        // Initialize external services
         mUploadIntent = new Intent(mLatinIME, UploaderService.class);
         mUploadNowIntent = new Intent(mLatinIME, UploaderService.class);
         mUploadNowIntent.putExtra(UploaderService.EXTRA_UPLOAD_UNCONDITIONALLY, true);
-        mReplayer.setKeyboardSwitcher(keyboardSwitcher);
-
         if (ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
             scheduleUploadingService(mLatinIME);
         }
+        mReplayer.setKeyboardSwitcher(keyboardSwitcher);
+    }
+
+    private void cleanLogDirectoryIfNeeded(final ResearchLogDirectory researchLogDirectory,
+            final long now) {
+        final long lastCleanupTime = ResearchSettings.readResearchLastDirCleanupTime(mPrefs);
+        if (now - lastCleanupTime < DURATION_BETWEEN_DIR_CLEANUP_IN_MS) return;
+        final long oldestAllowedFileTime = now - MAX_LOGFILE_AGE_IN_MS;
+        mResearchLogDirectory.cleanupLogFilesOlderThan(oldestAllowedFileTime);
+        ResearchSettings.writeResearchLastDirCleanupTime(mPrefs, now);
     }
 
     /**
@@ -304,17 +287,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
                 UploaderService.RUN_INTERVAL, UploaderService.RUN_INTERVAL, pendingIntent);
     }
 
-    private void cleanupLoggingDir(final File dir, final long time) {
-        for (File file : dir.listFiles()) {
-            final String filename = file.getName();
-            if ((filename.startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
-                    || filename.startsWith(ResearchLogger.USER_RECORDING_FILENAME_PREFIX))
-                    && file.lastModified() < time) {
-                file.delete();
-            }
-        }
-    }
-
     public void mainKeyboardView_onAttachedToWindow(final MainKeyboardView mainKeyboardView) {
         mMainKeyboardView = mainKeyboardView;
         maybeShowSplashScreen();
@@ -324,14 +296,16 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         mMainKeyboardView = null;
     }
 
-    private boolean hasSeenSplash() {
-        return mPrefs.getBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, false);
+    public void onDestroy() {
+        if (mPrefs != null) {
+            mPrefs.unregisterOnSharedPreferenceChangeListener(this);
+        }
     }
 
     private Dialog mSplashDialog = null;
 
     private void maybeShowSplashScreen() {
-        if (hasSeenSplash()) {
+        if (ResearchSettings.readHasSeenSplash(mPrefs)) {
             return;
         }
         if (mSplashDialog != null && mSplashDialog.isShowing()) {
@@ -384,53 +358,20 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     }
 
     public void onUserLoggingConsent() {
-        setLoggingAllowed(true);
         if (mPrefs == null) {
-            return;
+            mPrefs = PreferenceManager.getDefaultSharedPreferences(mLatinIME);
+            if (mPrefs == null) return;
         }
-        final Editor e = mPrefs.edit();
-        e.putBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, true);
-        e.apply();
+        sIsLogging = true;
+        ResearchSettings.writeResearchLoggerEnabledFlag(mPrefs, true);
+        ResearchSettings.writeHasSeenSplash(mPrefs, true);
         restart();
     }
 
-    private void setLoggingAllowed(boolean enableLogging) {
-        if (mPrefs == null) {
-            return;
-        }
-        Editor e = mPrefs.edit();
-        e.putBoolean(PREF_USABILITY_STUDY_MODE, enableLogging);
-        e.apply();
+    private void setLoggingAllowed(final boolean enableLogging) {
+        if (mPrefs == null) return;
         sIsLogging = enableLogging;
-    }
-
-    private static int sLogFileCounter = 0;
-
-    private File createLogFile(final File filesDir) {
-        final StringBuilder sb = new StringBuilder();
-        sb.append(LOG_FILENAME_PREFIX).append('-');
-        sb.append(mUUIDString).append('-');
-        sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-');
-        // Sometimes logFiles are created within milliseconds of each other.  Append a counter to
-        // separate these.
-        if (sLogFileCounter < Integer.MAX_VALUE) {
-            sLogFileCounter++;
-        } else {
-            // Wrap the counter, in the unlikely event of overflow.
-            sLogFileCounter = 0;
-        }
-        sb.append(sLogFileCounter);
-        sb.append(LOG_FILENAME_SUFFIX);
-        return new File(filesDir, sb.toString());
-    }
-
-    private File createUserRecordingFile(final File filesDir) {
-        final StringBuilder sb = new StringBuilder();
-        sb.append(USER_RECORDING_FILENAME_PREFIX).append('-');
-        sb.append(mUUIDString).append('-');
-        sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
-        sb.append(USER_RECORDING_FILENAME_SUFFIX);
-        return new File(filesDir, sb.toString());
+        ResearchSettings.writeResearchLoggerEnabledFlag(mPrefs, enableLogging);
     }
 
     private void checkForEmptyEditor() {
@@ -469,14 +410,12 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             // 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), mLatinIME);
+            mMainResearchLog = new ResearchLog(mResearchLogDirectory.getLogFilePath(
+                    System.currentTimeMillis()), mLatinIME);
             final int numWordsToIgnore = new Random().nextInt(NUMBER_OF_WORDS_BETWEEN_SAMPLES + 1);
-            mMainLogBuffer = new MainLogBuffer(NUMBER_OF_WORDS_BETWEEN_SAMPLES, numWordsToIgnore) {
+            mMainLogBuffer = new MainLogBuffer(NUMBER_OF_WORDS_BETWEEN_SAMPLES, numWordsToIgnore,
+                    mSuggest) {
                 @Override
                 protected void publish(final ArrayList<LogUnit> logUnits,
                         boolean canIncludePrivateData) {
@@ -499,7 +438,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
                     }
                 }
             };
-            mMainLogBuffer.setSuggest(mSuggest);
         }
         if (mFeedbackLogBuffer == null) {
             resetFeedbackLogging();
@@ -507,7 +445,8 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     }
 
     private void resetFeedbackLogging() {
-        mFeedbackLog = new ResearchLog(createLogFile(mFilesDir), mLatinIME);
+        mFeedbackLog = new ResearchLog(mResearchLogDirectory.getLogFilePath(
+                System.currentTimeMillis()), mLatinIME);
         mFeedbackLogBuffer = new FixedLogBuffer(FEEDBACK_WORD_BUFFER_SIZE);
     }
 
@@ -524,42 +463,29 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             commitCurrentLogUnit();
             mMainLogBuffer.setIsStopping();
             mMainLogBuffer.shiftAndPublishAll();
-            mMainResearchLog.close(null /* callback */);
+            mMainResearchLog.blockingClose(RESEARCHLOG_CLOSE_TIMEOUT_IN_MS);
             mMainLogBuffer = null;
         }
         if (mFeedbackLogBuffer != null) {
-            mFeedbackLog.close(null /* callback */);
+            mFeedbackLog.blockingClose(RESEARCHLOG_CLOSE_TIMEOUT_IN_MS);
             mFeedbackLogBuffer = null;
         }
     }
 
-    public boolean abort() {
+    public void abort() {
         if (DEBUG) {
             Log.d(TAG, "abort called");
         }
-        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.
-            }
+            mMainResearchLog.blockingAbort(RESEARCHLOG_ABORT_TIMEOUT_IN_MS);
             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.
-            }
+            mFeedbackLog.blockingAbort(RESEARCHLOG_ABORT_TIMEOUT_IN_MS);
             mFeedbackLogBuffer = null;
         }
-        return didAbortMainLog && didAbortFeedbackLog;
     }
 
     private void restart() {
@@ -576,7 +502,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     }
 
     @Override
-    public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
+    public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
         if (key == null || prefs == null) {
             return;
         }
@@ -598,7 +524,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         presentFeedbackDialog(latinIME);
     }
 
-    public void presentFeedbackDialog(LatinIME latinIME) {
+    public void presentFeedbackDialog(final LatinIME latinIME) {
         if (isMakingUserRecording()) {
             saveRecording();
         }
@@ -642,9 +568,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
 
     private void startRecordingInternal() {
         if (mUserRecordingLog != null) {
-            mUserRecordingLog.abort();
+            mUserRecordingLog.blockingAbort(RESEARCHLOG_ABORT_TIMEOUT_IN_MS);
         }
-        mUserRecordingFile = createUserRecordingFile(mFilesDir);
+        mUserRecordingFile = mResearchLogDirectory.getUserRecordingFilePath(
+                System.currentTimeMillis());
         mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME);
         mUserRecordingLogBuffer = new LogBuffer();
         resetRecordingTimer();
@@ -680,7 +607,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
 
     private void cancelRecording() {
         if (mUserRecordingLog != null) {
-            mUserRecordingLog.abort();
+            mUserRecordingLog.blockingAbort(RESEARCHLOG_ABORT_TIMEOUT_IN_MS);
         }
         mUserRecordingLog = null;
         mUserRecordingLogBuffer = null;
@@ -692,7 +619,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
     private void saveRecording() {
         commitCurrentLogUnit();
         publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true);
-        mUserRecordingLog.close(null);
+        mUserRecordingLog.blockingClose(RESEARCHLOG_CLOSE_TIMEOUT_IN_MS);
         mUserRecordingLog = null;
         mUserRecordingLogBuffer = null;
 
@@ -804,12 +731,8 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
                 feedbackContents, accountName, recording);
         mFeedbackLogBuffer.shiftIn(feedbackLogUnit);
         publishLogBuffer(mFeedbackLogBuffer, mSavedFeedbackLog, true /* isIncludingPrivateData */);
-        mSavedFeedbackLog.close(new Runnable() {
-            @Override
-            public void run() {
-                uploadNow();
-            }
-        });
+        mSavedFeedbackLog.blockingClose(RESEARCHLOG_CLOSE_TIMEOUT_IN_MS);
+        uploadNow();
 
         if (isIncludingRecording && DEBUG_REPLAY_AFTER_FEEDBACK) {
             final Handler handler = new Handler();
@@ -830,9 +753,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
             if (mPrefs == null) {
                 return;
             }
-            final Editor e = mPrefs.edit();
-            e.putString(PREF_RESEARCH_SAVED_CHANNEL, channelName);
-            e.apply();
+            mPrefs.edit().putString(PREF_RESEARCH_SAVED_CHANNEL, channelName).apply();
         }
     }
 
@@ -847,10 +768,13 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         mInFeedbackDialog = false;
     }
 
-    public void initSuggest(Suggest suggest) {
+    public void initSuggest(final Suggest suggest) {
         mSuggest = suggest;
+        // MainLogBuffer has out-of-date Suggest object.  Need to close it down and create a new
+        // one.
         if (mMainLogBuffer != null) {
-            mMainLogBuffer.setSuggest(mSuggest);
+            stop();
+            start();
         }
     }
 
@@ -1139,18 +1063,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
         }
     }
 
-    private static String getUUID(final SharedPreferences prefs) {
-        String uuidString = prefs.getString(PREF_RESEARCH_LOGGER_UUID_STRING, null);
-        if (null == uuidString) {
-            UUID uuid = UUID.randomUUID();
-            uuidString = uuid.toString();
-            Editor editor = prefs.edit();
-            editor.putString(PREF_RESEARCH_LOGGER_UUID_STRING, uuidString);
-            editor.apply();
-        }
-        return uuidString;
-    }
-
     private String scrubWord(String word) {
         final Dictionary dictionary = getDictionary();
         if (dictionary == null) {
@@ -1197,9 +1109,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
                         0);
                 final Integer versionCode = packageInfo.versionCode;
                 final String versionName = packageInfo.versionName;
+                final String uuid = ResearchSettings.readResearchLoggerUuid(researchLogger.mPrefs);
                 researchLogger.enqueueEvent(LOGSTATEMENT_LATIN_IME_ON_START_INPUT_VIEW_INTERNAL,
-                        researchLogger.mUUIDString, editorInfo.packageName,
-                        Integer.toHexString(editorInfo.inputType),
+                        uuid, editorInfo.packageName, Integer.toHexString(editorInfo.inputType),
                         Integer.toHexString(editorInfo.imeOptions), editorInfo.fieldId,
                         Build.DISPLAY, Build.MODEL, prefs, versionCode, versionName,
                         OUTPUT_FORMAT_VERSION, IS_LOGGING_EVERYTHING,
diff --git a/java/src/com/android/inputmethod/research/ResearchSettings.java b/java/src/com/android/inputmethod/research/ResearchSettings.java
new file mode 100644
index 0000000000000000000000000000000000000000..c0bc03fdec00a97cf7bd0977194dbb18f6037d13
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/ResearchSettings.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.research;
+
+import android.content.SharedPreferences;
+
+import java.util.UUID;
+
+public final class ResearchSettings {
+    public static final String PREF_RESEARCH_LOGGER_UUID = "pref_research_logger_uuid";
+    public static final String PREF_RESEARCH_LOGGER_ENABLED_FLAG =
+            "pref_research_logger_enabled_flag";
+    public static final String PREF_RESEARCH_LOGGER_HAS_SEEN_SPLASH =
+            "pref_research_logger_has_seen_splash";
+    public static final String PREF_RESEARCH_LAST_DIR_CLEANUP_TIME =
+            "pref_research_last_dir_cleanup_time";
+
+    private ResearchSettings() {
+        // Intentional empty constructor for singleton.
+    }
+
+    public static String readResearchLoggerUuid(final SharedPreferences prefs) {
+        if (prefs.contains(PREF_RESEARCH_LOGGER_UUID)) {
+            return prefs.getString(PREF_RESEARCH_LOGGER_UUID, null);
+        }
+        // Generate a random string as uuid if not yet set
+        final String newUuid = UUID.randomUUID().toString();
+        prefs.edit().putString(PREF_RESEARCH_LOGGER_UUID, newUuid).apply();
+        return newUuid;
+    }
+
+    public static boolean readResearchLoggerEnabledFlag(final SharedPreferences prefs) {
+        return prefs.getBoolean(PREF_RESEARCH_LOGGER_ENABLED_FLAG, false);
+    }
+
+    public static void writeResearchLoggerEnabledFlag(final SharedPreferences prefs,
+            final boolean isEnabled) {
+        prefs.edit().putBoolean(PREF_RESEARCH_LOGGER_ENABLED_FLAG, isEnabled).apply();
+    }
+
+    public static boolean readHasSeenSplash(final SharedPreferences prefs) {
+        return prefs.getBoolean(PREF_RESEARCH_LOGGER_HAS_SEEN_SPLASH, false);
+    }
+
+    public static void writeHasSeenSplash(final SharedPreferences prefs,
+            final boolean hasSeenSplash) {
+        prefs.edit().putBoolean(PREF_RESEARCH_LOGGER_HAS_SEEN_SPLASH, hasSeenSplash).apply();
+    }
+
+    public static long readResearchLastDirCleanupTime(final SharedPreferences prefs) {
+        return prefs.getLong(PREF_RESEARCH_LAST_DIR_CLEANUP_TIME, 0L);
+    }
+
+    public static void writeResearchLastDirCleanupTime(final SharedPreferences prefs,
+            final long lastDirCleanupTime) {
+        prefs.edit().putLong(PREF_RESEARCH_LAST_DIR_CLEANUP_TIME, lastDirCleanupTime).apply();
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/Uploader.java b/java/src/com/android/inputmethod/research/Uploader.java
new file mode 100644
index 0000000000000000000000000000000000000000..ba05ec12b07bee643bca282670885a5c25229a5e
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/Uploader.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.research;
+
+import android.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.text.TextUtils;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+import com.android.inputmethod.latin.define.ProductionFlag;
+
+import java.io.BufferedReader;
+import java.io.File;
+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;
+
+/**
+ * Manages the uploading of ResearchLog files.
+ */
+public final class Uploader {
+    private static final String TAG = Uploader.class.getSimpleName();
+    private static final boolean DEBUG = false
+            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
+    // Set IS_INHIBITING_AUTO_UPLOAD to true for local testing
+    private static final boolean IS_INHIBITING_AUTO_UPLOAD = false
+            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
+    private static final int BUF_SIZE = 1024 * 8;
+
+    private final Context mContext;
+    private final ResearchLogDirectory mResearchLogDirectory;
+    private final URL mUrl;
+
+    public Uploader(final Context context) {
+        mContext = context;
+        mResearchLogDirectory = new ResearchLogDirectory(context);
+
+        final String urlString = context.getString(R.string.research_logger_upload_url);
+        if (TextUtils.isEmpty(urlString)) {
+            mUrl = null;
+            return;
+        }
+        URL url = null;
+        try {
+            url = new URL(urlString);
+        } catch (final MalformedURLException e) {
+            Log.e(TAG, "Bad URL for uploading", e);
+        }
+        mUrl = url;
+    }
+
+    public boolean isPossibleToUpload() {
+        return hasUploadingPermission() && mUrl != null && !IS_INHIBITING_AUTO_UPLOAD;
+    }
+
+    private boolean hasUploadingPermission() {
+        final PackageManager packageManager = mContext.getPackageManager();
+        return packageManager.checkPermission(Manifest.permission.INTERNET,
+                mContext.getPackageName()) == PackageManager.PERMISSION_GRANTED;
+    }
+
+    public boolean isConvenientToUpload() {
+        return isExternallyPowered() && hasWifiConnection();
+    }
+
+    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();
+    }
+
+    public void doUpload() {
+        final File[] files = mResearchLogDirectory.getUploadableLogFiles();
+        if (files == null) return;
+        for (final File file : files) {
+            uploadFile(file);
+        }
+    }
+
+    private void uploadFile(final File file) {
+        if (DEBUG) {
+            Log.d(TAG, "attempting upload of " + file.getAbsolutePath());
+        }
+        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 outputStream = connection.getOutputStream();
+            uploadContents(fileInputStream, outputStream);
+            if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+                Log.d(TAG, "upload failed: " + connection.getResponseCode());
+                final InputStream netInputStream = connection.getInputStream();
+                final BufferedReader reader = new BufferedReader(new InputStreamReader(
+                        netInputStream));
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    Log.d(TAG, "| " + reader.readLine());
+                }
+                reader.close();
+                return;
+            }
+            file.delete();
+            if (DEBUG) {
+                Log.d(TAG, "upload successful");
+            }
+        } catch (final IOException e) {
+            Log.e(TAG, "Exception uploading file", e);
+        } finally {
+            if (fileInputStream != null) {
+                try {
+                    fileInputStream.close();
+                } catch (final IOException e) {
+                    Log.e(TAG, "Exception closing uploaded file", e);
+                }
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    private static void uploadContents(final InputStream is, final OutputStream os)
+            throws IOException {
+        // TODO: Switch to NIO.
+        final byte[] buf = new byte[BUF_SIZE];
+        int numBytesRead;
+        while ((numBytesRead = is.read(buf)) != -1) {
+            os.write(buf, 0, numBytesRead);
+        }
+    }
+}
diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java
index 5a6b627041b5d34199e7b9aea487216a95f3a84d..6a9f5c1f48535de33f5f8b1eeeb866fb05da6bea 100644
--- a/java/src/com/android/inputmethod/research/UploaderService.java
+++ b/java/src/com/android/inputmethod/research/UploaderService.java
@@ -16,190 +16,45 @@
 
 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 com.android.inputmethod.latin.define.ProductionFlag;
 
-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;
-
+/**
+ * Service to invoke the uploader.
+ *
+ * Can be regularly invoked, invoked on boot, etc.
+ */
 public final class UploaderService extends IntentService {
     private static final String TAG = UploaderService.class.getSimpleName();
     private static final boolean DEBUG = false
             && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
-    // Set IS_INHIBITING_AUTO_UPLOAD to true for local testing
-    private static final boolean IS_INHIBITING_AUTO_UPLOAD = false
-            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
     public static final long RUN_INTERVAL = AlarmManager.INTERVAL_HOUR;
     public 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();
+    protected void onHandleIntent(final Intent intent) {
+        final Uploader uploader = new Uploader(this);
+        if (!uploader.isPossibleToUpload()) return;
+        if (isUploadingUnconditionally(intent.getExtras()) || uploader.isConvenientToUpload()) {
+            uploader.doUpload();
         }
     }
 
-    @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()
-                || IS_INHIBITING_AUTO_UPLOAD)) {
-            return;
-        }
-        if (mFilesDir == null) {
-            return;
-        }
-        final File[] files = mFilesDir.listFiles(new FileFilter() {
-            @Override
-            public boolean accept(File pathname) {
-                return pathname.getName().startsWith(ResearchLogger.LOG_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) {
-        if (DEBUG) {
-            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 (DEBUG) {
-                    Log.d(TAG, new String(buf));
-                }
-            }
-            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;
-            if (DEBUG) {
-                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();
-            }
+    private boolean isUploadingUnconditionally(final Bundle bundle) {
+        if (bundle == null) return false;
+        if (bundle.containsKey(EXTRA_UPLOAD_UNCONDITIONALLY)) {
+            return bundle.getBoolean(EXTRA_UPLOAD_UNCONDITIONALLY);
         }
-        return success;
+        return false;
     }
 }