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