Merge "[Rlog27] Add replay capability"

main
Kurt Partridge 2013-01-30 14:38:15 +00:00 committed by Android (Google) Code Review
commit 4e049897ef
7 changed files with 581 additions and 88 deletions

View File

@ -428,7 +428,7 @@ public final class LatinIME extends InputMethodService implements KeyboardAction
initSuggest(); initSuggest();
if (ProductionFlag.IS_EXPERIMENTAL) { if (ProductionFlag.IS_EXPERIMENTAL) {
ResearchLogger.getInstance().init(this); ResearchLogger.getInstance().init(this, mKeyboardSwitcher);
} }
mDisplayOrientation = getResources().getConfiguration().orientation; mDisplayOrientation = getResources().getConfiguration().orientation;

View File

@ -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;
}
}

View File

@ -26,15 +26,12 @@ import android.view.inputmethod.CompletionInfo;
import com.android.inputmethod.keyboard.Key; import com.android.inputmethod.keyboard.Key;
import com.android.inputmethod.latin.SuggestedWords; import com.android.inputmethod.latin.SuggestedWords;
import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
import com.android.inputmethod.latin.Utils;
import com.android.inputmethod.latin.define.ProductionFlag; import com.android.inputmethod.latin.define.ProductionFlag;
import com.android.inputmethod.research.ResearchLogger.LogStatement;
import java.io.IOException; import java.io.IOException;
import java.io.StringWriter; import java.io.StringWriter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* A group of log statements related to each other. * A group of log statements related to each other.
@ -53,6 +50,7 @@ import java.util.Map;
/* package */ class LogUnit { /* package */ class LogUnit {
private static final String TAG = LogUnit.class.getSimpleName(); private static final String TAG = LogUnit.class.getSimpleName();
private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG;
private final ArrayList<LogStatement> mLogStatementList; private final ArrayList<LogStatement> mLogStatementList;
private final ArrayList<Object[]> mValuesList; private final ArrayList<Object[]> mValuesList;
// Assume that mTimeList is sorted in increasing order. Do not insert null values into // 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; JsonWriter jsonWriter = null;
for (int i = 0; i < size; i++) { for (int i = 0; i < size; i++) {
final LogStatement logStatement = mLogStatementList.get(i); final LogStatement logStatement = mLogStatementList.get(i);
if (!canIncludePrivateData && logStatement.mIsPotentiallyPrivate) { if (!canIncludePrivateData && logStatement.isPotentiallyPrivate()) {
continue; continue;
} }
if (mIsPartOfMegaword && logStatement.mIsPotentiallyRevealing) { if (mIsPartOfMegaword && logStatement.isPotentiallyRevealing()) {
continue; continue;
} }
// Only retrieve the jsonWriter if we need to. If we don't get this far, then // 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, private boolean outputLogStatementToLocked(final JsonWriter jsonWriter,
final LogStatement logStatement, final Object[] values, final Long time) { final LogStatement logStatement, final Object[] values, final Long time) {
if (DEBUG) { if (DEBUG) {
if (logStatement.mKeys.length != values.length) { if (logStatement.getKeys().length != values.length) {
Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.mName); Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.getType());
} }
} }
try { try {
jsonWriter.beginObject(); jsonWriter.beginObject();
jsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis()); jsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis());
jsonWriter.name(UPTIME_KEY).value(time); jsonWriter.name(UPTIME_KEY).value(time);
jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.mName); jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.getType());
final String[] keys = logStatement.mKeys; final String[] keys = logStatement.getKeys();
final int length = values.length; final int length = values.length;
for (int i = 0; i < length; i++) { for (int i = 0; i < length; i++) {
jsonWriter.name(keys[i]); jsonWriter.name(keys[i]);
@ -261,8 +259,8 @@ import java.util.Map;
} else if (value == null) { } else if (value == null) {
jsonWriter.nullValue(); jsonWriter.nullValue();
} else { } else {
Log.w(TAG, "Unrecognized type to be logged: " + Log.w(TAG, "Unrecognized type to be logged: "
(value == null ? "<null>" : value.getClass().getName())); + (value == null ? "<null>" : value.getClass().getName()));
jsonWriter.nullValue(); jsonWriter.nullValue();
} }
} }
@ -422,4 +420,123 @@ import java.util.Map;
} }
return false; 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;
}
} }

View File

@ -0,0 +1,113 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.android.inputmethod.research;
import android.util.JsonReader;
import android.util.Log;
import android.view.MotionEvent;
import com.android.inputmethod.latin.define.ProductionFlag;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
public class MotionEventReader {
private static final String TAG = MotionEventReader.class.getSimpleName();
private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG;
public ReplayData readMotionEventData(final File file) {
final ReplayData replayData = new ReplayData();
try {
// Read file
final JsonReader jsonReader = new JsonReader(new BufferedReader(new InputStreamReader(
new FileInputStream(file))));
jsonReader.beginArray();
while (jsonReader.hasNext()) {
readLogStatement(jsonReader, replayData);
}
jsonReader.endArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return replayData;
}
static class ReplayData {
final ArrayList<Integer> mActions = new ArrayList<Integer>();
final ArrayList<Integer> mXCoords = new ArrayList<Integer>();
final ArrayList<Integer> mYCoords = new ArrayList<Integer>();
final ArrayList<Long> mTimes = new ArrayList<Long>();
}
private void readLogStatement(final JsonReader jsonReader, final ReplayData replayData)
throws IOException {
String logStatementType = null;
Integer actionType = null;
Integer x = null;
Integer y = null;
Long time = null;
boolean loggingRelated = false;
jsonReader.beginObject();
while (jsonReader.hasNext()) {
final String key = jsonReader.nextName();
if (key.equals("_ty")) {
logStatementType = jsonReader.nextString();
} else if (key.equals("_ut")) {
time = jsonReader.nextLong();
} else if (key.equals("x")) {
x = jsonReader.nextInt();
} else if (key.equals("y")) {
y = jsonReader.nextInt();
} else if (key.equals("action")) {
final String s = jsonReader.nextString();
if (s.equals("UP")) {
actionType = MotionEvent.ACTION_UP;
} else if (s.equals("DOWN")) {
actionType = MotionEvent.ACTION_DOWN;
} else if (s.equals("MOVE")) {
actionType = MotionEvent.ACTION_MOVE;
}
} else if (key.equals("loggingRelated")) {
loggingRelated = jsonReader.nextBoolean();
} else {
if (DEBUG) {
Log.w(TAG, "Unknown JSON key in LogStatement: " + key);
}
jsonReader.skipValue();
}
}
jsonReader.endObject();
if (logStatementType != null && time != null && x != null && y != null && actionType != null
&& logStatementType.equals("MainKeyboardViewProcessMotionEvent")
&& !loggingRelated) {
replayData.mActions.add(actionType);
replayData.mXCoords.add(x);
replayData.mYCoords.add(y);
replayData.mTimes.add(time);
}
}
}

View File

@ -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);
}
}

View File

@ -57,6 +57,7 @@ import android.widget.Toast;
import com.android.inputmethod.keyboard.Key; import com.android.inputmethod.keyboard.Key;
import com.android.inputmethod.keyboard.Keyboard; import com.android.inputmethod.keyboard.Keyboard;
import com.android.inputmethod.keyboard.KeyboardId; import com.android.inputmethod.keyboard.KeyboardId;
import com.android.inputmethod.keyboard.KeyboardSwitcher;
import com.android.inputmethod.keyboard.KeyboardView; import com.android.inputmethod.keyboard.KeyboardView;
import com.android.inputmethod.keyboard.MainKeyboardView; import com.android.inputmethod.keyboard.MainKeyboardView;
import com.android.inputmethod.latin.Constants; 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 int OUTPUT_FORMAT_VERSION = 5;
private static final String PREF_USABILITY_STUDY_MODE = "usability_study_mode"; 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"; private static final String PREF_RESEARCH_HAS_SEEN_SPLASH = "pref_research_has_seen_splash";
/* package */ static final String FILENAME_PREFIX = "researchLog"; /* package */ static final String LOG_FILENAME_PREFIX = "researchLog";
private static final String FILENAME_SUFFIX = ".txt"; 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 = private static final SimpleDateFormat TIMESTAMP_DATEFORMAT =
new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US); new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
// Whether to show an indicator on the screen that logging is on. Currently a very small red // 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. // the system to do so.
// LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are // LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are
// complete. // complete.
/* package */ ResearchLog mFeedbackLog;
/* package */ MainLogBuffer mMainLogBuffer; /* 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 */ LogBuffer mFeedbackLogBuffer;
/* package */ ResearchLog mUserRecordingLog;
/* package */ LogBuffer mUserRecordingLogBuffer;
private File mUserRecordingFile = null;
private boolean mIsPasswordView = false; private boolean mIsPasswordView = false;
private boolean mIsLoggingSuspended = false; private boolean mIsLoggingSuspended = false;
@ -155,6 +164,8 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
private MainKeyboardView mMainKeyboardView; private MainKeyboardView mMainKeyboardView;
private LatinIME mLatinIME; private LatinIME mLatinIME;
private final Statistics mStatistics; private final Statistics mStatistics;
private final MotionEventReader mMotionEventReader = new MotionEventReader();
private final Replayer mReplayer = new Replayer();
private Intent mUploadIntent; private Intent mUploadIntent;
@ -173,7 +184,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
return sInstance; return sInstance;
} }
public void init(final LatinIME latinIME) { public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) {
assert latinIME != null; assert latinIME != null;
if (latinIME == null) { if (latinIME == null) {
Log.w(TAG, "IMS is null; logging is off"); Log.w(TAG, "IMS is null; logging is off");
@ -210,6 +221,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
mLatinIME = latinIME; mLatinIME = latinIME;
mPrefs = prefs; mPrefs = prefs;
mUploadIntent = new Intent(mLatinIME, UploaderService.class); mUploadIntent = new Intent(mLatinIME, UploaderService.class);
mReplayer.setKeyboardSwitcher(keyboardSwitcher);
if (ProductionFlag.IS_EXPERIMENTAL) { if (ProductionFlag.IS_EXPERIMENTAL) {
scheduleUploadingService(mLatinIME); scheduleUploadingService(mLatinIME);
@ -237,8 +249,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
private void cleanupLoggingDir(final File dir, final long time) { private void cleanupLoggingDir(final File dir, final long time) {
for (File file : dir.listFiles()) { for (File file : dir.listFiles()) {
if (file.getName().startsWith(ResearchLogger.FILENAME_PREFIX) && final String filename = file.getName();
file.lastModified() < time) { if ((filename.startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
|| filename.startsWith(ResearchLogger.USER_RECORDING_FILENAME_PREFIX))
&& file.lastModified() < time) {
file.delete(); file.delete();
} }
} }
@ -335,9 +349,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
private static int sLogFileCounter = 0; private static int sLogFileCounter = 0;
private File createLogFile(File filesDir) { private File createLogFile(final File filesDir) {
final StringBuilder sb = new StringBuilder(); final StringBuilder sb = new StringBuilder();
sb.append(FILENAME_PREFIX).append('-'); sb.append(LOG_FILENAME_PREFIX).append('-');
sb.append(mUUIDString).append('-'); sb.append(mUUIDString).append('-');
sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-'); sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-');
// Sometimes logFiles are created within milliseconds of each other. Append a counter to // 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; sLogFileCounter = 0;
} }
sb.append(sLogFileCounter); 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()); return new File(filesDir, sb.toString());
} }
@ -517,37 +540,32 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
presentFeedbackDialog(latinIME); presentFeedbackDialog(latinIME);
} }
// TODO: currently unreachable. Remove after being sure no menu is needed. private void cancelRecording() {
/* if (mUserRecordingLog != null) {
public void presentResearchDialog(final LatinIME latinIME) { mUserRecordingLog.abort();
final CharSequence title = latinIME.getString(R.string.english_ime_research_log); }
final boolean showEnable = mIsLoggingSuspended || !sIsLogging; mUserRecordingLog = null;
final CharSequence[] items = new CharSequence[] { mUserRecordingLogBuffer = null;
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) private void startRecording() {
}; // Don't record the "start recording" motion.
final DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { commitCurrentLogUnit();
@Override if (mUserRecordingLog != null) {
public void onClick(DialogInterface di, int position) { mUserRecordingLog.abort();
di.dismiss(); }
switch (position) { mUserRecordingFile = createUserRecordingFile(mFilesDir);
case 0: mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME);
presentFeedbackDialog(latinIME); mUserRecordingLogBuffer = new LogBuffer();
break; }
case 1:
enableOrDisable(showEnable, latinIME); private void saveRecording() {
break; commitCurrentLogUnit();
} publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true);
} mUserRecordingLog.close(null);
mUserRecordingLog = null;
}; mUserRecordingLogBuffer = null;
final AlertDialog.Builder builder = new AlertDialog.Builder(latinIME)
.setItems(items, listener)
.setTitle(title);
latinIME.showOptionDialog(builder.create());
} }
*/
private boolean mInFeedbackDialog = false; private boolean mInFeedbackDialog = false;
@ -631,38 +649,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
return null; 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 = private static final LogStatement LOGSTATEMENT_FEEDBACK =
new LogStatement("UserFeedback", false, false, "contents", "accountName"); new LogStatement("UserFeedback", false, false, "contents", "accountName");
public void sendFeedback(final String feedbackContents, final boolean includeHistory, 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, private synchronized void enqueueEvent(final LogUnit logUnit, final LogStatement logStatement,
final Object... values) { final Object... values) {
assert values.length == logStatement.mKeys.length; assert values.length == logStatement.getKeys().length;
if (isAllowedToLog() && logUnit != null) { if (isAllowedToLog() && logUnit != null) {
final long time = SystemClock.uptimeMillis(); final long time = SystemClock.uptimeMillis();
logUnit.addLogStatement(logStatement, time, values); logUnit.addLogStatement(logStatement, time, values);
@ -801,6 +787,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
if (mFeedbackLogBuffer != null) { if (mFeedbackLogBuffer != null) {
mFeedbackLogBuffer.shiftIn(mCurrentLogUnit); mFeedbackLogBuffer.shiftIn(mCurrentLogUnit);
} }
if (mUserRecordingLogBuffer != null) {
mUserRecordingLogBuffer.shiftIn(mCurrentLogUnit);
}
mCurrentLogUnit = new LogUnit(); mCurrentLogUnit = new LogUnit();
} else { } else {
if (DEBUG) { if (DEBUG) {
@ -1058,7 +1047,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
* *
*/ */
private static final LogStatement LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT = 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, 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) { final long eventTime, final int index, final int id, final int x, final int y) {
if (me != null) { if (me != null) {
@ -1075,7 +1064,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
} }
final ResearchLogger researchLogger = getInstance(); final ResearchLogger researchLogger = getInstance();
researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT, researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT,
actionString, MotionEvent.obtain(me)); actionString, MotionEvent.obtain(me), false);
if (action == MotionEvent.ACTION_DOWN) { if (action == MotionEvent.ACTION_DOWN) {
// Subtract 1 from eventTime so the down event is included in the later // Subtract 1 from eventTime so the down event is included in the later
// LogUnit, not the earlier (the test is for inequality). // LogUnit, not the earlier (the test is for inequality).
@ -1442,13 +1431,21 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang
final int code) { final int code) {
if (key != null) { if (key != null) {
String outputText = key.getOutputText(); String outputText = key.getOutputText();
getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT, final ResearchLogger researchLogger = getInstance();
researchLogger.enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT,
Constants.printableCode(scrubDigitFromCodePoint(code)), Constants.printableCode(scrubDigitFromCodePoint(code)),
outputText == null ? null : scrubDigitsFromString(outputText.toString()), outputText == null ? null : scrubDigitsFromString(outputText.toString()),
x, y, ignoreModifierKey, altersCode, key.isEnabled()); x, y, ignoreModifierKey, altersCode, key.isEnabled());
if (code == Constants.CODE_RESEARCH) {
researchLogger.suppressResearchKeyMotionData();
}
} }
} }
private void suppressResearchKeyMotionData() {
mCurrentLogUnit.removeResearchButtonInvocation();
}
/** /**
* Log a call to PointerTracker.callListenerCallListenerOnRelease(). * Log a call to PointerTracker.callListenerCallListenerOnRelease().
* *

View File

@ -131,7 +131,7 @@ public final class UploaderService extends IntentService {
final File[] files = mFilesDir.listFiles(new FileFilter() { final File[] files = mFilesDir.listFiles(new FileFilter() {
@Override @Override
public boolean accept(File pathname) { public boolean accept(File pathname) {
return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX) return pathname.getName().startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
&& !pathname.canWrite(); && !pathname.canWrite();
} }
}); });