[Rlog27] Add replay capability

- Add support for replaying log files to the ResearchLogger.  This will let
  users preview data that they choose to upload.
- When the user explicitly requests that the system record their action, it
  will record everything up to, and including, the motion involved in shutting
  off the recording.  This change also removes the stop-recording motion
  commands.

Change-Id: Ib1df383bbf1881512cb111fab9f6749c25e436ba
main
Kurt Partridge 2013-01-21 15:13:05 -08:00
parent 3079b71964
commit f3731188e5
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();
} }
}); });