diff options
Diffstat (limited to 'java/src/com/android/inputmethod/research')
18 files changed, 3319 insertions, 936 deletions
diff --git a/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java index 5124a35a6..c5f095919 100644 --- a/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java +++ b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * 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; diff --git a/java/src/com/android/inputmethod/research/FeedbackActivity.java b/java/src/com/android/inputmethod/research/FeedbackActivity.java index 11eae8813..b985fda21 100644 --- a/java/src/com/android/inputmethod/research/FeedbackActivity.java +++ b/java/src/com/android/inputmethod/research/FeedbackActivity.java @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * 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; @@ -28,25 +28,10 @@ public class FeedbackActivity extends Activity { super.onCreate(savedInstanceState); setContentView(R.layout.research_feedback_activity); final FeedbackLayout layout = (FeedbackLayout) findViewById(R.id.research_feedback_layout); - final CheckBox checkbox = (CheckBox) findViewById(R.id.research_feedback_include_history); - final CharSequence cs = checkbox.getText(); - final String actualString = String.format(cs.toString(), - ResearchLogger.FEEDBACK_WORD_BUFFER_SIZE); - checkbox.setText(actualString); layout.setActivity(this); } @Override - protected void onResume() { - super.onResume(); - } - - @Override - protected void onPause() { - super.onPause(); - } - - @Override public void onBackPressed() { ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); super.onBackPressed(); diff --git a/java/src/com/android/inputmethod/research/FeedbackFragment.java b/java/src/com/android/inputmethod/research/FeedbackFragment.java index a2e08e2b7..39f9c87a0 100644 --- a/java/src/com/android/inputmethod/research/FeedbackFragment.java +++ b/java/src/com/android/inputmethod/research/FeedbackFragment.java @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * 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; @@ -20,6 +20,8 @@ import android.app.Activity; import android.app.Fragment; import android.os.Bundle; import android.text.Editable; +import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; @@ -27,12 +29,22 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.CheckBox; import android.widget.EditText; +import android.widget.Toast; import com.android.inputmethod.latin.R; -public class FeedbackFragment extends Fragment { +public class FeedbackFragment extends Fragment implements OnClickListener { + private static final String TAG = FeedbackFragment.class.getSimpleName(); + + public static final String KEY_FEEDBACK_STRING = "FeedbackString"; + public static final String KEY_INCLUDE_ACCOUNT_NAME = "IncludeAccountName"; + public static final String KEY_HAS_USER_RECORDING = "HasRecording"; + private EditText mEditText; - private CheckBox mCheckBox; + private CheckBox mIncludingAccountNameCheckBox; + private CheckBox mIncludingUserRecordingCheckBox; + private Button mSendButton; + private Button mCancelButton; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -40,34 +52,102 @@ public class FeedbackFragment extends Fragment { final View view = inflater.inflate(R.layout.research_feedback_fragment_layout, container, false); mEditText = (EditText) view.findViewById(R.id.research_feedback_contents); - mCheckBox = (CheckBox) view.findViewById(R.id.research_feedback_include_history); - - final Button sendButton = (Button) view.findViewById( - R.id.research_feedback_send_button); - sendButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - final Editable editable = mEditText.getText(); - final String feedbackContents = editable.toString(); - final boolean includeHistory = mCheckBox.isChecked(); - ResearchLogger.getInstance().sendFeedback(feedbackContents, includeHistory); - final Activity activity = FeedbackFragment.this.getActivity(); - activity.finish(); - ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); + mEditText.requestFocus(); + mIncludingAccountNameCheckBox = (CheckBox) view.findViewById( + R.id.research_feedback_include_account_name); + mIncludingUserRecordingCheckBox = (CheckBox) view.findViewById( + R.id.research_feedback_include_recording_checkbox); + mIncludingUserRecordingCheckBox.setOnClickListener(this); + + mSendButton = (Button) view.findViewById(R.id.research_feedback_send_button); + mSendButton.setOnClickListener(this); + mCancelButton = (Button) view.findViewById(R.id.research_feedback_cancel_button); + mCancelButton.setOnClickListener(this); + + if (savedInstanceState != null) { + Log.d(TAG, "restoring from savedInstanceState"); + restoreState(savedInstanceState); + } else { + final Bundle bundle = getActivity().getIntent().getExtras(); + if (bundle != null) { + Log.d(TAG, "restoring from getArguments()"); + restoreState(bundle); } - }); - - final Button cancelButton = (Button) view.findViewById( - R.id.research_feedback_cancel_button); - cancelButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - final Activity activity = FeedbackFragment.this.getActivity(); - activity.finish(); - ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); + } + return view; + } + + @Override + public void onClick(final View view) { + final ResearchLogger researchLogger = ResearchLogger.getInstance(); + if (view == mIncludingUserRecordingCheckBox) { + if (hasUserRecording()) { + // Remove the recording + setHasUserRecording(false); + } else { + final Bundle bundle = new Bundle(); + onSaveInstanceState(bundle); + + // Let the user make a recording + getActivity().finish(); + + researchLogger.setFeedbackDialogBundle(bundle); + researchLogger.onLeavingSendFeedbackDialog(); + researchLogger.startRecording(); } - }); + } else if (view == mSendButton) { + final Editable editable = mEditText.getText(); + final String feedbackContents = editable.toString(); + if (TextUtils.isEmpty(feedbackContents)) { + Toast.makeText(getActivity(), + R.string.research_feedback_empty_feedback_error_message, + Toast.LENGTH_LONG).show(); + } else { + final boolean isIncludingAccountName = isIncludingAccountName(); + researchLogger.sendFeedback(feedbackContents, + false /* isIncludingHistory */, isIncludingAccountName, hasUserRecording()); + getActivity().finish(); + researchLogger.setFeedbackDialogBundle(null); + researchLogger.onLeavingSendFeedbackDialog(); + } + } else if (view == mCancelButton) { + Log.d(TAG, "Finishing"); + getActivity().finish(); + researchLogger.setFeedbackDialogBundle(null); + researchLogger.onLeavingSendFeedbackDialog(); + } else { + Log.e(TAG, "Unknown view passed to FeedbackFragment.onClick()"); + } + } - return view; + @Override + public void onSaveInstanceState(final Bundle bundle) { + final String savedFeedbackString = mEditText.getText().toString(); + + bundle.putString(KEY_FEEDBACK_STRING, savedFeedbackString); + bundle.putBoolean(KEY_INCLUDE_ACCOUNT_NAME, isIncludingAccountName()); + bundle.putBoolean(KEY_HAS_USER_RECORDING, hasUserRecording()); + } + + public void restoreState(final Bundle bundle) { + mEditText.setText(bundle.getString(KEY_FEEDBACK_STRING)); + setIsIncludingAccountName(bundle.getBoolean(KEY_INCLUDE_ACCOUNT_NAME)); + setHasUserRecording(bundle.getBoolean(KEY_HAS_USER_RECORDING)); + } + + private boolean hasUserRecording() { + return mIncludingUserRecordingCheckBox.isChecked(); + } + + private void setHasUserRecording(final boolean hasRecording) { + mIncludingUserRecordingCheckBox.setChecked(hasRecording); + } + + private boolean isIncludingAccountName() { + return mIncludingAccountNameCheckBox.isChecked(); + } + + private void setIsIncludingAccountName(final boolean isIncludingAccountName) { + mIncludingAccountNameCheckBox.setChecked(isIncludingAccountName); } } diff --git a/java/src/com/android/inputmethod/research/FeedbackLayout.java b/java/src/com/android/inputmethod/research/FeedbackLayout.java index f2cbfe308..d283d14b2 100644 --- a/java/src/com/android/inputmethod/research/FeedbackLayout.java +++ b/java/src/com/android/inputmethod/research/FeedbackLayout.java @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * 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; diff --git a/java/src/com/android/inputmethod/research/FixedLogBuffer.java b/java/src/com/android/inputmethod/research/FixedLogBuffer.java new file mode 100644 index 000000000..78dc59562 --- /dev/null +++ b/java/src/com/android/inputmethod/research/FixedLogBuffer.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.research; + +import java.util.ArrayList; +import java.util.LinkedList; + +/** + * A buffer that holds a fixed number of LogUnits. + * + * LogUnits are added in and shifted out in temporal order. Only a subset of the LogUnits are + * actual words; the other LogUnits do not count toward the word limit. Once the buffer reaches + * capacity, adding another LogUnit that is a word evicts the oldest LogUnits out one at a time to + * stay under the capacity limit. + * + * This variant of a LogBuffer has a limited memory footprint because of its limited size. This + * makes it useful, for example, for recording a window of the user's most recent actions in case + * they want to report an observed error that they do not know how to reproduce. + */ +public class FixedLogBuffer extends LogBuffer { + /* package for test */ int mWordCapacity; + // The number of members of mLogUnits that are actual words. + private int mNumActualWords; + + /** + * Create a new LogBuffer that can hold a fixed number of LogUnits that are words (and + * unlimited number of non-word LogUnits), and that outputs its result to a researchLog. + * + * @param wordCapacity maximum number of words + */ + public FixedLogBuffer(final int wordCapacity) { + super(); + if (wordCapacity <= 0) { + throw new IllegalArgumentException("wordCapacity must be 1 or greater."); + } + mWordCapacity = wordCapacity; + mNumActualWords = 0; + } + + protected int getNumActualWords() { + return mNumActualWords; + } + + /** + * Adds a new LogUnit to the front of the LIFO queue, evicting existing LogUnit's + * (oldest first) if word capacity is reached. + */ + @Override + public void shiftIn(final LogUnit newLogUnit) { + if (!newLogUnit.hasWord()) { + // This LogUnit isn't a word, so it doesn't count toward the word-limit. + super.shiftIn(newLogUnit); + return; + } + if (mNumActualWords >= mWordCapacity) { + // Give subclass a chance to handle the buffer full condition by shifting out logUnits. + onBufferFull(); + // If still full, evict. + if (mNumActualWords >= mWordCapacity) { + shiftOutWords(1); + } + } + super.shiftIn(newLogUnit); + mNumActualWords++; // Must be a word, or we wouldn't be here. + } + + @Override + public LogUnit unshiftIn() { + final LogUnit logUnit = super.unshiftIn(); + if (logUnit != null && logUnit.hasWord()) { + mNumActualWords--; + } + return logUnit; + } + + public int getNumWords() { + return mNumActualWords; + } + + /** + * Removes all LogUnits from the buffer without calling onShiftOut(). + */ + @Override + public void clear() { + super.clear(); + mNumActualWords = 0; + } + + /** + * Called when the buffer has just shifted in one more word than its maximum, and its about to + * shift out LogUnits to bring it back down to the maximum. + * + * Base class does nothing; subclasses may override if they want to record non-privacy sensitive + * events that fall off the end. + */ + protected void onBufferFull() { + } + + @Override + public LogUnit shiftOut() { + final LogUnit logUnit = super.shiftOut(); + if (logUnit != null && logUnit.hasWord()) { + mNumActualWords--; + } + return logUnit; + } + + protected void shiftOutWords(final int numWords) { + final int targetNumWords = mNumActualWords - numWords; + final LinkedList<LogUnit> logUnits = getLogUnits(); + while (mNumActualWords > targetNumWords && !logUnits.isEmpty()) { + shiftOut(); + } + } + + public void shiftOutAll() { + final LinkedList<LogUnit> logUnits = getLogUnits(); + while (!logUnits.isEmpty()) { + shiftOut(); + } + mNumActualWords = 0; + } + + /** + * Returns a list of {@link LogUnit}s at the front of the buffer that have associated words. No + * more than {@code n} LogUnits will have words associated with them. If there are not enough + * LogUnits in the buffer to meet the word requirement, returns the all LogUnits. + * + * @param n The maximum number of {@link LogUnit}s with words to return. + * @return The list of the {@link LogUnit}s containing the first n words + */ + public ArrayList<LogUnit> peekAtFirstNWords(int n) { + final LinkedList<LogUnit> logUnits = getLogUnits(); + final int length = logUnits.size(); + // Allocate space for n*2 logUnits. There will be at least n, one for each word, and + // there may be additional for punctuation, between-word commands, etc. This should be + // enough that reallocation won't be necessary. + final ArrayList<LogUnit> list = new ArrayList<LogUnit>(n * 2); + for (int i = 0; i < length && n > 0; i++) { + final LogUnit logUnit = logUnits.get(i); + list.add(logUnit); + if (logUnit.hasWord()) { + n--; + } + } + return list; + } +} diff --git a/java/src/com/android/inputmethod/research/JsonUtils.java b/java/src/com/android/inputmethod/research/JsonUtils.java new file mode 100644 index 000000000..24cd8d935 --- /dev/null +++ b/java/src/com/android/inputmethod/research/JsonUtils.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.research; + +import android.content.SharedPreferences; +import android.util.JsonWriter; +import android.view.MotionEvent; +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 java.io.IOException; +import java.util.Map; + +/** + * Routines for mapping classes and variables to JSON representations for logging. + */ +/* package */ class JsonUtils { + private JsonUtils() { + // This utility class is not publicly instantiable. + } + + /* package */ static void writeJson(final CompletionInfo[] ci, final JsonWriter jsonWriter) + throws IOException { + jsonWriter.beginArray(); + for (int j = 0; j < ci.length; j++) { + jsonWriter.value(ci[j].toString()); + } + jsonWriter.endArray(); + } + + /* package */ static void writeJson(final SharedPreferences prefs, final JsonWriter jsonWriter) + throws IOException { + jsonWriter.beginObject(); + for (Map.Entry<String,?> entry : prefs.getAll().entrySet()) { + jsonWriter.name(entry.getKey()); + final Object innerValue = entry.getValue(); + if (innerValue == null) { + jsonWriter.nullValue(); + } else if (innerValue instanceof Boolean) { + jsonWriter.value((Boolean) innerValue); + } else if (innerValue instanceof Number) { + jsonWriter.value((Number) innerValue); + } else { + jsonWriter.value(innerValue.toString()); + } + } + jsonWriter.endObject(); + } + + /* package */ static void writeJson(final Key[] keys, final JsonWriter jsonWriter) + throws IOException { + jsonWriter.beginArray(); + for (Key key : keys) { + writeJson(key, jsonWriter); + } + jsonWriter.endArray(); + } + + private static void writeJson(final Key key, final JsonWriter jsonWriter) throws IOException { + jsonWriter.beginObject(); + jsonWriter.name("code").value(key.mCode); + jsonWriter.name("altCode").value(key.getAltCode()); + jsonWriter.name("x").value(key.mX); + jsonWriter.name("y").value(key.mY); + jsonWriter.name("w").value(key.mWidth); + jsonWriter.name("h").value(key.mHeight); + jsonWriter.endObject(); + } + + /* package */ static void writeJson(final SuggestedWords words, final JsonWriter jsonWriter) + throws IOException { + jsonWriter.beginObject(); + jsonWriter.name("typedWordValid").value(words.mTypedWordValid); + jsonWriter.name("willAutoCorrect") + .value(words.mWillAutoCorrect); + jsonWriter.name("isPunctuationSuggestions") + .value(words.mIsPunctuationSuggestions); + jsonWriter.name("isObsoleteSuggestions").value(words.mIsObsoleteSuggestions); + jsonWriter.name("isPrediction").value(words.mIsPrediction); + jsonWriter.name("words"); + jsonWriter.beginArray(); + final int size = words.size(); + for (int j = 0; j < size; j++) { + final SuggestedWordInfo wordInfo = words.getInfo(j); + jsonWriter.value(wordInfo.toString()); + } + jsonWriter.endArray(); + jsonWriter.endObject(); + } + + /* package */ static void writeJson(final MotionEvent me, final JsonWriter jsonWriter) + throws IOException { + jsonWriter.beginObject(); + jsonWriter.name("pointerIds"); + jsonWriter.beginArray(); + final int pointerCount = me.getPointerCount(); + for (int index = 0; index < pointerCount; index++) { + jsonWriter.value(me.getPointerId(index)); + } + jsonWriter.endArray(); + + jsonWriter.name("xyt"); + jsonWriter.beginArray(); + final int historicalSize = me.getHistorySize(); + for (int index = 0; index < historicalSize; index++) { + jsonWriter.beginObject(); + jsonWriter.name("t"); + jsonWriter.value(me.getHistoricalEventTime(index)); + jsonWriter.name("d"); + jsonWriter.beginArray(); + for (int pointerIndex = 0; pointerIndex < pointerCount; pointerIndex++) { + jsonWriter.beginObject(); + jsonWriter.name("x"); + jsonWriter.value(me.getHistoricalX(pointerIndex, index)); + jsonWriter.name("y"); + jsonWriter.value(me.getHistoricalY(pointerIndex, index)); + jsonWriter.endObject(); + } + jsonWriter.endArray(); + jsonWriter.endObject(); + } + jsonWriter.beginObject(); + jsonWriter.name("t"); + jsonWriter.value(me.getEventTime()); + jsonWriter.name("d"); + jsonWriter.beginArray(); + for (int pointerIndex = 0; pointerIndex < pointerCount; pointerIndex++) { + jsonWriter.beginObject(); + jsonWriter.name("x"); + jsonWriter.value(me.getX(pointerIndex)); + jsonWriter.name("y"); + jsonWriter.value(me.getY(pointerIndex)); + jsonWriter.endObject(); + } + jsonWriter.endArray(); + jsonWriter.endObject(); + jsonWriter.endArray(); + jsonWriter.endObject(); + } +} diff --git a/java/src/com/android/inputmethod/research/LogBuffer.java b/java/src/com/android/inputmethod/research/LogBuffer.java index ae7b1579a..b07b761f0 100644 --- a/java/src/com/android/inputmethod/research/LogBuffer.java +++ b/java/src/com/android/inputmethod/research/LogBuffer.java @@ -1,113 +1,73 @@ /* * 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 + * 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 + * 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. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.android.inputmethod.research; -import com.android.inputmethod.latin.CollectionUtils; - import java.util.LinkedList; /** - * A buffer that holds a fixed number of LogUnits. + * Maintain a FIFO queue of LogUnits. * - * LogUnits are added in and shifted out in temporal order. Only a subset of the LogUnits are - * actual words; the other LogUnits do not count toward the word limit. Once the buffer reaches - * capacity, adding another LogUnit that is a word evicts the oldest LogUnits out one at a time to - * stay under the capacity limit. + * This class provides an unbounded queue. This is useful when the user is aware that their actions + * are being recorded, such as when they are trying to reproduce a bug. In this case, there should + * not be artificial restrictions on how many events that can be saved. */ public class LogBuffer { - protected final LinkedList<LogUnit> mLogUnits; - /* package for test */ int mWordCapacity; - // The number of members of mLogUnits that are actual words. - protected int mNumActualWords; + // TODO: Gracefully handle situations in which this LogBuffer is consuming too much memory. + // This may happen, for example, if the user has forgotten that data is being logged. + private final LinkedList<LogUnit> mLogUnits; - /** - * Create a new LogBuffer that can hold a fixed number of LogUnits that are words (and - * unlimited number of non-word LogUnits), and that outputs its result to a researchLog. - * - * @param wordCapacity maximum number of words - */ - LogBuffer(final int wordCapacity) { - if (wordCapacity <= 0) { - throw new IllegalArgumentException("wordCapacity must be 1 or greater."); - } - mLogUnits = CollectionUtils.newLinkedList(); - mWordCapacity = wordCapacity; - mNumActualWords = 0; - } - - /** - * Adds a new LogUnit to the front of the LIFO queue, evicting existing LogUnit's - * (oldest first) if word capacity is reached. - */ - public void shiftIn(LogUnit newLogUnit) { - if (newLogUnit.getWord() == null) { - // This LogUnit isn't a word, so it doesn't count toward the word-limit. - mLogUnits.add(newLogUnit); - return; - } - if (mNumActualWords == mWordCapacity) { - shiftOutThroughFirstWord(); - } - mLogUnits.add(newLogUnit); - mNumActualWords++; // Must be a word, or we wouldn't be here. + public LogBuffer() { + mLogUnits = new LinkedList<LogUnit>(); } - private void shiftOutThroughFirstWord() { - while (!mLogUnits.isEmpty()) { - final LogUnit logUnit = mLogUnits.removeFirst(); - onShiftOut(logUnit); - if (logUnit.hasWord()) { - // Successfully shifted out a word-containing LogUnit and made space for the new - // LogUnit. - mNumActualWords--; - break; - } - } + protected LinkedList<LogUnit> getLogUnits() { + return mLogUnits; } - /** - * Removes all LogUnits from the buffer without calling onShiftOut(). - */ public void clear() { mLogUnits.clear(); - mNumActualWords = 0; } - /** - * Called when a LogUnit is removed from the LogBuffer as a result of a shiftIn. LogUnits are - * removed in the order entered. This method is not called when shiftOut is called directly. - * - * Base class does nothing; subclasses may override. - */ - protected void onShiftOut(LogUnit logUnit) { + public void shiftIn(final LogUnit logUnit) { + mLogUnits.add(logUnit); } - /** - * Called to deliberately remove the oldest LogUnit. Usually called when draining the - * LogBuffer. - */ - public LogUnit shiftOut() { + public LogUnit unshiftIn() { if (mLogUnits.isEmpty()) { return null; } - final LogUnit logUnit = mLogUnits.removeFirst(); - if (logUnit.hasWord()) { - mNumActualWords--; + return mLogUnits.removeLast(); + } + + public LogUnit peekLastLogUnit() { + if (mLogUnits.isEmpty()) { + return null; + } + return mLogUnits.peekLast(); + } + + public boolean isEmpty() { + return mLogUnits.isEmpty(); + } + + public LogUnit shiftOut() { + if (isEmpty()) { + return null; } - return logUnit; + return mLogUnits.removeFirst(); } } 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..09b12fcfa --- /dev/null +++ b/java/src/com/android/inputmethod/research/LogStatement.java @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.research; + +import android.content.SharedPreferences; +import android.util.JsonWriter; +import android.util.Log; +import android.view.MotionEvent; +import android.view.inputmethod.CompletionInfo; + +import com.android.inputmethod.keyboard.Key; +import com.android.inputmethod.latin.SuggestedWords; +import com.android.inputmethod.latin.define.ProductionFlag; + +import java.io.IOException; + +/** + * 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. + */ +public class LogStatement { + private static final String TAG = LogStatement.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + + // 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_MAIN_KEYBOARD_VIEW_ON_LONG_PRESS = + "MainKeyboardViewOnLongPress"; + public static final String ACTION = "action"; + public static final String VALUE_DOWN = "DOWN"; + public static final String TYPE_MOTION_EVENT = "MotionEvent"; + public static final String KEY_IS_LOGGING_RELATED = "isLoggingRelated"; + + // Keys for internal key/value pairs + private static final String CURRENT_TIME_KEY = "_ct"; + private static final String UPTIME_KEY = "_ut"; + private static final String EVENT_TYPE_KEY = "_ty"; + + // 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; + } + + /** + * Write the contents out through jsonWriter. + * + * The JsonWriter class must have already had {@code JsonWriter.beginArray} called on it. + * + * Note that this method is not thread safe for the same jsonWriter. Callers must ensure + * thread safety. + */ + public boolean outputToLocked(final JsonWriter jsonWriter, final Long time, + final Object... values) { + if (DEBUG) { + if (mKeys.length != values.length) { + Log.d(TAG, "Key and Value list sizes do not match. " + mType); + } + } + try { + jsonWriter.beginObject(); + jsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis()); + jsonWriter.name(UPTIME_KEY).value(time); + jsonWriter.name(EVENT_TYPE_KEY).value(mType); + final int length = values.length; + for (int i = 0; i < length; i++) { + jsonWriter.name(mKeys[i]); + final Object value = values[i]; + if (value instanceof CharSequence) { + jsonWriter.value(value.toString()); + } else if (value instanceof Number) { + jsonWriter.value((Number) value); + } else if (value instanceof Boolean) { + jsonWriter.value((Boolean) value); + } else if (value instanceof CompletionInfo[]) { + JsonUtils.writeJson((CompletionInfo[]) value, jsonWriter); + } else if (value instanceof SharedPreferences) { + JsonUtils.writeJson((SharedPreferences) value, jsonWriter); + } else if (value instanceof Key[]) { + JsonUtils.writeJson((Key[]) value, jsonWriter); + } else if (value instanceof SuggestedWords) { + JsonUtils.writeJson((SuggestedWords) value, jsonWriter); + } else if (value instanceof MotionEvent) { + JsonUtils.writeJson((MotionEvent) value, jsonWriter); + } else if (value == null) { + jsonWriter.nullValue(); + } else { + if (DEBUG) { + Log.w(TAG, "Unrecognized type to be logged: " + + (value == null ? "<null>" : value.getClass().getName())); + } + jsonWriter.nullValue(); + } + } + jsonWriter.endObject(); + } catch (IOException e) { + e.printStackTrace(); + Log.w(TAG, "Error in JsonWriter; skipping LogStatement"); + return false; + } + return true; + } +} diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java index d8b3a29ff..e91976a03 100644 --- a/java/src/com/android/inputmethod/research/LogUnit.java +++ b/java/src/com/android/inputmethod/research/LogUnit.java @@ -1,24 +1,35 @@ /* * 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 + * 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 + * 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. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.android.inputmethod.research; -import com.android.inputmethod.latin.CollectionUtils; +import android.content.SharedPreferences; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.JsonWriter; +import android.util.Log; +import com.android.inputmethod.latin.SuggestedWords; +import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import com.android.inputmethod.latin.define.ProductionFlag; + +import java.io.IOException; +import java.io.StringWriter; import java.util.ArrayList; +import java.util.List; /** * A group of log statements related to each other. @@ -35,29 +46,200 @@ import java.util.ArrayList; * been published recently, or whether the LogUnit contains numbers, etc. */ /* package */ class LogUnit { - private final ArrayList<String[]> mKeysList = CollectionUtils.newArrayList(); - private final ArrayList<Object[]> mValuesList = CollectionUtils.newArrayList(); - private final ArrayList<Boolean> mIsPotentiallyPrivate = CollectionUtils.newArrayList(); + private static final String TAG = LogUnit.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + + private final ArrayList<LogStatement> mLogStatementList; + private final ArrayList<Object[]> mValuesList; + // Assume that mTimeList is sorted in increasing order. Do not insert null values into + // mTimeList. + private final ArrayList<Long> mTimeList; + // Word that this LogUnit generates. Should be null if the LogUnit does not generate a genuine + // word (i.e. separators alone do not count as a word). Should never be empty. private String mWord; - private boolean mContainsDigit; + private boolean mMayContainDigit; + private boolean mIsPartOfMegaword; + private boolean mContainsCorrection; + + // mCorrectionType indicates whether the word was corrected at all, and if so, whether it was + // to a different word or just a "typo" correction. It is considered a "typo" if the final + // word was listed in the suggestions available the first time the word was gestured or + // tapped. + private int mCorrectionType; + public static final int CORRECTIONTYPE_NO_CORRECTION = 0; + public static final int CORRECTIONTYPE_CORRECTION = 1; + public static final int CORRECTIONTYPE_DIFFERENT_WORD = 2; + public static final int CORRECTIONTYPE_TYPO = 3; - public void addLogStatement(final String[] keys, final Object[] values, - final Boolean isPotentiallyPrivate) { - mKeysList.add(keys); + private SuggestedWords mSuggestedWords; + + public LogUnit() { + mLogStatementList = new ArrayList<LogStatement>(); + mValuesList = new ArrayList<Object[]>(); + mTimeList = new ArrayList<Long>(); + mIsPartOfMegaword = false; + mCorrectionType = CORRECTIONTYPE_NO_CORRECTION; + mSuggestedWords = null; + } + + private LogUnit(final ArrayList<LogStatement> logStatementList, + final ArrayList<Object[]> valuesList, + final ArrayList<Long> timeList, + final boolean isPartOfMegaword) { + mLogStatementList = logStatementList; + mValuesList = valuesList; + mTimeList = timeList; + mIsPartOfMegaword = isPartOfMegaword; + mCorrectionType = CORRECTIONTYPE_NO_CORRECTION; + mSuggestedWords = null; + } + + private static final Object[] NULL_VALUES = new Object[0]; + /** + * Adds a new log statement. The time parameter in successive calls to this method must be + * monotonically increasing, or splitByTime() will not work. + */ + public void addLogStatement(final LogStatement logStatement, final long time, + Object... values) { + if (values == null) { + values = NULL_VALUES; + } + mLogStatementList.add(logStatement); mValuesList.add(values); - mIsPotentiallyPrivate.add(isPotentiallyPrivate); + mTimeList.add(time); } - public void publishTo(final ResearchLog researchLog, final boolean isIncludingPrivateData) { - final int size = mKeysList.size(); - for (int i = 0; i < size; i++) { - if (!mIsPotentiallyPrivate.get(i) || isIncludingPrivateData) { - researchLog.outputEvent(mKeysList.get(i), mValuesList.get(i)); + /** + * Publish the contents of this LogUnit to {@code researchLog}. + * + * For each publishable {@code LogStatement}, invoke {@link LogStatement#outputToLocked}. + * + * @param researchLog where to publish the contents of this {@code LogUnit} + * @param canIncludePrivateData whether the private data in this {@code LogUnit} should be + * included + */ + public synchronized void publishTo(final ResearchLog researchLog, + final boolean canIncludePrivateData) { + // Prepare debugging output if necessary + final StringWriter debugStringWriter; + final JsonWriter debugJsonWriter; + if (DEBUG) { + debugStringWriter = new StringWriter(); + debugJsonWriter = new JsonWriter(debugStringWriter); + debugJsonWriter.setIndent(" "); + try { + debugJsonWriter.beginArray(); + } catch (IOException e) { + Log.e(TAG, "Could not open array in JsonWriter", e); + } + } else { + debugStringWriter = null; + debugJsonWriter = null; + } + // Write out any logStatement that passes the privacy filter. + final int size = mLogStatementList.size(); + if (size != 0) { + // Note that jsonWriter is only set to a non-null value if the logUnit start text is + // output and at least one logStatement is output. + JsonWriter jsonWriter = null; + for (int i = 0; i < size; i++) { + final LogStatement logStatement = mLogStatementList.get(i); + if (!canIncludePrivateData && logStatement.isPotentiallyPrivate()) { + continue; + } + if (mIsPartOfMegaword && logStatement.isPotentiallyRevealing()) { + continue; + } + // Only retrieve the jsonWriter if we need to. If we don't get this far, then + // researchLog.getInitializedJsonWriterLocked() will not ever be called, and the + // file will not have been opened for writing. + if (jsonWriter == null) { + jsonWriter = researchLog.getInitializedJsonWriterLocked(); + outputLogUnitStart(jsonWriter, canIncludePrivateData); + } + logStatement.outputToLocked(jsonWriter, mTimeList.get(i), mValuesList.get(i)); + if (DEBUG) { + logStatement.outputToLocked(debugJsonWriter, mTimeList.get(i), + mValuesList.get(i)); + } } + if (jsonWriter != null) { + // We must have called logUnitStart earlier, so emit a logUnitStop. + outputLogUnitStop(jsonWriter); + } + } + if (DEBUG) { + try { + debugJsonWriter.endArray(); + debugJsonWriter.flush(); + } catch (IOException e) { + Log.e(TAG, "Could not close array in JsonWriter", e); + } + final String bigString = debugStringWriter.getBuffer().toString(); + final String[] lines = bigString.split("\n"); + for (String line : lines) { + Log.d(TAG, line); + } + } + } + + private static final String WORD_KEY = "_wo"; + private static final String CORRECTION_TYPE_KEY = "_corType"; + private static final String LOG_UNIT_BEGIN_KEY = "logUnitStart"; + private static final String LOG_UNIT_END_KEY = "logUnitEnd"; + + final LogStatement LOGSTATEMENT_LOG_UNIT_BEGIN_WITH_PRIVATE_DATA = + new LogStatement(LOG_UNIT_BEGIN_KEY, false /* isPotentiallyPrivate */, + false /* isPotentiallyRevealing */, WORD_KEY, CORRECTION_TYPE_KEY); + final LogStatement LOGSTATEMENT_LOG_UNIT_BEGIN_WITHOUT_PRIVATE_DATA = + new LogStatement(LOG_UNIT_BEGIN_KEY, false /* isPotentiallyPrivate */, + false /* isPotentiallyRevealing */); + private void outputLogUnitStart(final JsonWriter jsonWriter, + final boolean canIncludePrivateData) { + final LogStatement logStatement; + if (canIncludePrivateData) { + LOGSTATEMENT_LOG_UNIT_BEGIN_WITH_PRIVATE_DATA.outputToLocked(jsonWriter, + SystemClock.uptimeMillis(), getWord(), getCorrectionType()); + } else { + LOGSTATEMENT_LOG_UNIT_BEGIN_WITHOUT_PRIVATE_DATA.outputToLocked(jsonWriter, + SystemClock.uptimeMillis()); } } - public void setWord(String word) { + final LogStatement LOGSTATEMENT_LOG_UNIT_END = + new LogStatement(LOG_UNIT_END_KEY, false /* isPotentiallyPrivate */, + false /* isPotentiallyRevealing */); + private void outputLogUnitStop(final JsonWriter jsonWriter) { + LOGSTATEMENT_LOG_UNIT_END.outputToLocked(jsonWriter, SystemClock.uptimeMillis()); + } + + /** + * Mark the current logUnit as containing data to generate {@code word}. + * + * If {@code setWord()} was previously called for this LogUnit, then the method will try to + * determine what kind of correction it is, and update its internal state of the correctionType + * accordingly. + * + * @param word The word this LogUnit generates. Caller should not pass null or the empty + * string. + */ + public void setWord(final String word) { + if (hasWord()) { + // The word was already set once, and it is now being changed. See if the new word + // is close to the old word. If so, then the change is probably a typo correction. + // If not, the user may have decided to enter a different word, so flag it. + if (mSuggestedWords != null) { + if (isInSuggestedWords(word, mSuggestedWords)) { + mCorrectionType = CORRECTIONTYPE_TYPO; + } else { + mCorrectionType = CORRECTIONTYPE_DIFFERENT_WORD; + } + } else { + // No suggested words, so it's not clear whether it's a typo or different word. + // Mark it as a generic correction. + mCorrectionType = CORRECTIONTYPE_CORRECTION; + } + } mWord = word; } @@ -66,18 +248,234 @@ import java.util.ArrayList; } public boolean hasWord() { - return mWord != null; + return mWord != null && !TextUtils.isEmpty(mWord.trim()); + } + + public void setMayContainDigit() { + mMayContainDigit = true; + } + + public boolean mayContainDigit() { + return mMayContainDigit; + } + + public void setContainsCorrection() { + mContainsCorrection = true; + } + + public boolean containsCorrection() { + return mContainsCorrection; } - public void setContainsDigit() { - mContainsDigit = true; + public void setCorrectionType(final int correctionType) { + mCorrectionType = correctionType; } - public boolean hasDigit() { - return mContainsDigit; + public int getCorrectionType() { + return mCorrectionType; } public boolean isEmpty() { - return mKeysList.isEmpty(); + return mLogStatementList.isEmpty(); + } + + /** + * Split this logUnit, with all events before maxTime staying in the current logUnit, and all + * events after maxTime going into a new LogUnit that is returned. + */ + public LogUnit splitByTime(final long maxTime) { + // Assume that mTimeList is in sorted order. + final int length = mTimeList.size(); + // TODO: find time by binary search, e.g. using Collections#binarySearch() + for (int index = 0; index < length; index++) { + if (mTimeList.get(index) > maxTime) { + final List<LogStatement> laterLogStatements = + mLogStatementList.subList(index, length); + final List<Object[]> laterValues = mValuesList.subList(index, length); + final List<Long> laterTimes = mTimeList.subList(index, length); + + // Create the LogUnit containing the later logStatements and associated data. + final LogUnit newLogUnit = new LogUnit( + new ArrayList<LogStatement>(laterLogStatements), + new ArrayList<Object[]>(laterValues), + new ArrayList<Long>(laterTimes), + true /* isPartOfMegaword */); + newLogUnit.mWord = null; + newLogUnit.mMayContainDigit = mMayContainDigit; + newLogUnit.mContainsCorrection = mContainsCorrection; + + // Purge the logStatements and associated data from this LogUnit. + laterLogStatements.clear(); + laterValues.clear(); + laterTimes.clear(); + mIsPartOfMegaword = true; + + return newLogUnit; + } + } + return new LogUnit(); + } + + public void append(final LogUnit logUnit) { + mLogStatementList.addAll(logUnit.mLogStatementList); + mValuesList.addAll(logUnit.mValuesList); + mTimeList.addAll(logUnit.mTimeList); + mWord = null; + if (logUnit.mWord != null) { + setWord(logUnit.mWord); + } + mMayContainDigit = mMayContainDigit || logUnit.mMayContainDigit; + mContainsCorrection = mContainsCorrection || logUnit.mContainsCorrection; + mIsPartOfMegaword = false; + } + + public SuggestedWords getSuggestions() { + return mSuggestedWords; + } + + /** + * Initialize the suggestions. + * + * Once set to a non-null value, the suggestions may not be changed again. This is to keep + * track of the list of words that are close to the user's initial effort to type the word. + * Only words that are close to the initial effort are considered typo corrections. + */ + public void initializeSuggestions(final SuggestedWords suggestedWords) { + if (mSuggestedWords == null) { + mSuggestedWords = suggestedWords; + } + } + + private static boolean isInSuggestedWords(final String queryWord, + final SuggestedWords suggestedWords) { + if (TextUtils.isEmpty(queryWord)) { + return false; + } + final int size = suggestedWords.size(); + for (int i = 0; i < size; i++) { + final SuggestedWordInfo wordInfo = suggestedWords.getInfo(i); + if (queryWord.equals(wordInfo.mWord)) { + return true; + } + } + 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_MAIN_KEYBOARD_VIEW_ON_LONG_PRESS, + indexOfLastResearchKey); + + // Look for DOWN event preceding the long press + final int indexOfLastDownEventBeforeLongPress = + findLastIndexContainingKeyValueBefore(LogStatement.TYPE_MOTION_EVENT, + 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_MOTION_EVENT)) { + logStatement.setValue(LogStatement.KEY_IS_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/LoggingUtils.java b/java/src/com/android/inputmethod/research/LoggingUtils.java new file mode 100644 index 000000000..1261d6780 --- /dev/null +++ b/java/src/com/android/inputmethod/research/LoggingUtils.java @@ -0,0 +1,38 @@ +/* + * 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.view.MotionEvent; + +/* package */ class LoggingUtils { + private LoggingUtils() { + // This utility class is not publicly instantiable. + } + + /* package */ static String getMotionEventActionTypeString(final int actionType) { + switch (actionType) { + case MotionEvent.ACTION_CANCEL: return "CANCEL"; + case MotionEvent.ACTION_UP: return "UP"; + case MotionEvent.ACTION_DOWN: return "DOWN"; + case MotionEvent.ACTION_POINTER_UP: return "POINTER_UP"; + case MotionEvent.ACTION_POINTER_DOWN: return "POINTER_DOWN"; + case MotionEvent.ACTION_MOVE: return "MOVE"; + case MotionEvent.ACTION_OUTSIDE: return "OUTSIDE"; + default: return "ACTION_" + actionType; + } + } +} diff --git a/java/src/com/android/inputmethod/research/MainLogBuffer.java b/java/src/com/android/inputmethod/research/MainLogBuffer.java index 745768d35..45b83dd76 100644 --- a/java/src/com/android/inputmethod/research/MainLogBuffer.java +++ b/java/src/com/android/inputmethod/research/MainLogBuffer.java @@ -1,75 +1,104 @@ /* * 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 + * 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 + * 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. + * 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.Log; + import com.android.inputmethod.latin.Dictionary; import com.android.inputmethod.latin.Suggest; +import com.android.inputmethod.latin.define.ProductionFlag; +import java.util.ArrayList; +import java.util.LinkedList; import java.util.Random; -public class MainLogBuffer extends LogBuffer { +/** + * MainLogBuffer is a FixedLogBuffer that tracks the state of LogUnits to make privacy guarantees. + * + * There are three forms of privacy protection: 1) only words in the main dictionary are allowed to + * be logged in enough detail to determine their contents, 2) only a subset of words are logged + * in detail, such as 10%, and 3) no numbers are logged. + * + * This class maintains a list of LogUnits, each corresponding to a word. As the user completes + * words, they are added here. But if the user backs up over their current word to edit a word + * entered earlier, then it is pulled out of this LogBuffer, changes are then added to the end of + * the LogUnit, and it is pushed back in here when the user is done. Because words may be pulled + * back out even after they are pushed in, we must not publish the contents of this LogBuffer too + * quickly. However, we cannot let the contents pile up either, or it will limit the editing that + * a user can perform. + * + * To balance these requirements (keep history so user can edit, flush history so it does not pile + * up), the LogBuffer is considered "complete" when the user has entered enough words to form an + * n-gram, followed by enough additional non-detailed words (that are in the 90%, as per above). + * Once complete, the n-gram may be published to flash storage (via the ResearchLog class). + * However, the additional non-detailed words are retained, in case the user backspaces to edit + * them. The MainLogBuffer then continues to add words, publishing individual non-detailed words + * as new words arrive. After enough non-detailed words have been pushed out to account for the + * 90% between words, the words at the front of the LogBuffer can be published as an n-gram again. + * + * If the words that would form the valid n-gram are not in the dictionary, then words are pushed + * through the LogBuffer one at a time until an n-gram is found that is entirely composed of + * dictionary words. + * + * If the user closes a session, then the entire LogBuffer is flushed, publishing any embedded + * n-gram containing dictionary words. + */ +public abstract class MainLogBuffer extends FixedLogBuffer { + private static final String TAG = MainLogBuffer.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + // The size of the n-grams logged. E.g. N_GRAM_SIZE = 2 means to sample bigrams. - private static final int N_GRAM_SIZE = 2; - // The number of words between n-grams to omit from the log. - private static final int DEFAULT_NUMBER_OF_WORDS_BETWEEN_SAMPLES = 18; + public static final int N_GRAM_SIZE = 2; - private final ResearchLog mResearchLog; private Suggest mSuggest; + private boolean mIsStopping = false; - // The minimum periodicity with which n-grams can be sampled. E.g. mWinWordPeriod is 10 if - // every 10th bigram is sampled, i.e., words 1-8 are not, but the bigram at words 9 and 10, etc. - // for 11-18, and the bigram at words 19 and 20. If an n-gram is not safe (e.g. it contains a - // number in the middle or an out-of-vocabulary word), then sampling is delayed until a safe - // n-gram does appear. - /* package for test */ int mMinWordPeriod; + /* package for test */ int mNumWordsBetweenNGrams; // Counter for words left to suppress before an n-gram can be sampled. Reset to mMinWordPeriod // after a sample is taken. - /* package for test */ int mWordsUntilSafeToSample; - - public MainLogBuffer(final ResearchLog researchLog) { - super(N_GRAM_SIZE); - mResearchLog = researchLog; - mMinWordPeriod = DEFAULT_NUMBER_OF_WORDS_BETWEEN_SAMPLES + N_GRAM_SIZE; - final Random random = new Random(); - mWordsUntilSafeToSample = random.nextInt(mMinWordPeriod); + /* package for test */ int mNumWordsUntilSafeToSample; + + public MainLogBuffer(final int wordsBetweenSamples, final int numInitialWordsToIgnore) { + super(N_GRAM_SIZE + wordsBetweenSamples); + mNumWordsBetweenNGrams = wordsBetweenSamples; + mNumWordsUntilSafeToSample = DEBUG ? 0 : numInitialWordsToIgnore; } - public void setSuggest(Suggest suggest) { + public void setSuggest(final Suggest suggest) { mSuggest = suggest; } - @Override - public void shiftIn(final LogUnit newLogUnit) { - super.shiftIn(newLogUnit); - if (newLogUnit.hasWord()) { - if (mWordsUntilSafeToSample > 0) { - mWordsUntilSafeToSample--; - } - } + private Dictionary getDictionary() { + if (mSuggest == null || !mSuggest.hasMainDictionary()) return null; + return mSuggest.getMainDictionary(); } public void resetWordCounter() { - mWordsUntilSafeToSample = mMinWordPeriod; + mNumWordsUntilSafeToSample = mNumWordsBetweenNGrams; + } + + public void setIsStopping() { + mIsStopping = true; } /** - * Determines whether the content of the MainLogBuffer can be safely uploaded in its complete - * form and still protect the user's privacy. + * Determines whether uploading the n words at the front the MainLogBuffer will not violate + * user privacy. * * The size of the MainLogBuffer is just enough to hold one n-gram, its corrections, and any * non-character data that is typed between words. The decision about privacy is made based on @@ -78,50 +107,118 @@ public class MainLogBuffer extends LogBuffer { * the screen orientation and other characteristics about the device can be uploaded without * revealing much about the user. */ - public boolean isSafeToLog() { + private boolean isSafeNGram(final ArrayList<LogUnit> logUnits, final int minNGramSize) { + // Bypass privacy checks when debugging. + if (ResearchLogger.IS_LOGGING_EVERYTHING) { + if (mIsStopping) { + return true; + } + // Only check that it is the right length. If not, wait for later words to make + // complete n-grams. + int numWordsInLogUnitList = 0; + final int length = logUnits.size(); + for (int i = 0; i < length; i++) { + final LogUnit logUnit = logUnits.get(i); + final String word = logUnit.getWord(); + if (word != null) { + numWordsInLogUnitList++; + } + } + return numWordsInLogUnitList >= minNGramSize; + } + // Check that we are not sampling too frequently. Having sampled recently might disclose // too much of the user's intended meaning. - if (mWordsUntilSafeToSample > 0) { - return false; - } - if (mSuggest == null || !mSuggest.hasMainDictionary()) { - // Main dictionary is unavailable. Since we cannot check it, we cannot tell if a word - // is out-of-vocabulary or not. Therefore, we must judge the entire buffer contents to - // potentially pose a privacy risk. + if (mNumWordsUntilSafeToSample > 0) { return false; } // Reload the dictionary in case it has changed (e.g., because the user has changed // languages). - final Dictionary dictionary = mSuggest.getMainDictionary(); + final Dictionary dictionary = getDictionary(); if (dictionary == null) { + // Main dictionary is unavailable. Since we cannot check it, we cannot tell if a + // word is out-of-vocabulary or not. Therefore, we must judge the entire buffer + // contents to potentially pose a privacy risk. return false; } - // Check each word in the buffer. If any word poses a privacy threat, we cannot upload the - // complete buffer contents in detail. - final int length = mLogUnits.size(); + + // Check each word in the buffer. If any word poses a privacy threat, we cannot upload + // the complete buffer contents in detail. + int numWordsInLogUnitList = 0; + final int length = logUnits.size(); for (int i = 0; i < length; i++) { - final LogUnit logUnit = mLogUnits.get(i); - final String word = logUnit.getWord(); - if (word == null) { + final LogUnit logUnit = logUnits.get(i); + if (!logUnit.hasWord()) { // Digits outside words are a privacy threat. - if (logUnit.hasDigit()) { + if (logUnit.mayContainDigit()) { return false; } } else { + numWordsInLogUnitList++; + final String word = logUnit.getWord(); // Words not in the dictionary are a privacy threat. - if (!(dictionary.isValidWord(word))) { + if (ResearchLogger.hasLetters(word) && !(dictionary.isValidWord(word))) { + if (DEBUG) { + Log.d(TAG, "NOT SAFE!: hasLetters: " + ResearchLogger.hasLetters(word) + + ", isValid: " + (dictionary.isValidWord(word))); + } return false; } } } - // All checks have passed; this buffer's content can be safely uploaded. - return true; + + // Finally, only return true if the minNGramSize is met. + return numWordsInLogUnitList >= minNGramSize; + } + + public void shiftAndPublishAll() { + final LinkedList<LogUnit> logUnits = getLogUnits(); + while (!logUnits.isEmpty()) { + publishLogUnitsAtFrontOfBuffer(); + } + } + + @Override + protected final void onBufferFull() { + publishLogUnitsAtFrontOfBuffer(); + } + + protected final void publishLogUnitsAtFrontOfBuffer() { + ArrayList<LogUnit> logUnits = peekAtFirstNWords(N_GRAM_SIZE); + if (isSafeNGram(logUnits, N_GRAM_SIZE)) { + // Good n-gram at the front of the buffer. Publish it, disclosing details. + publish(logUnits, true /* canIncludePrivateData */); + shiftOutWords(N_GRAM_SIZE); + resetWordCounter(); + } else { + // No good n-gram at front, and buffer is full. Shift out the first word (or if there + // is none, the existing logUnits). + logUnits = peekAtFirstNWords(1); + publish(logUnits, false /* canIncludePrivateData */); + shiftOutWords(1); + } } + /** + * Called when a list of logUnits should be published. + * + * It is the subclass's responsibility to implement the publication. + * + * @param logUnits The list of logUnits to be published. + * @param canIncludePrivateData Whether the private data in the logUnits can be included in + * publication. + */ + protected abstract void publish(final ArrayList<LogUnit> logUnits, + final boolean canIncludePrivateData); + @Override - protected void onShiftOut(LogUnit logUnit) { - if (mResearchLog != null) { - mResearchLog.publish(logUnit, false /* isIncludingPrivateData */); + protected void shiftOutWords(final int numWords) { + final int oldNumActualWords = getNumActualWords(); + super.shiftOutWords(numWords); + final int numWordsShifted = oldNumActualWords - getNumActualWords(); + mNumWordsUntilSafeToSample -= numWordsShifted; + if (DEBUG) { + Log.d(TAG, "wordsUntilSafeToSample now at " + mNumWordsUntilSafeToSample); } } } 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..e59adfa19 --- /dev/null +++ b/java/src/com/android/inputmethod/research/MotionEventReader.java @@ -0,0 +1,332 @@ +/* + * 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 android.view.MotionEvent.PointerCoords; +import android.view.MotionEvent.PointerProperties; + +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; + // Assumes that MotionEvent.ACTION_MASK does not have all bits set.` + private static final int UNINITIALIZED_ACTION = ~MotionEvent.ACTION_MASK; + // No legitimate int is negative + private static final int UNINITIALIZED_INT = -1; + // No legitimate long is negative + private static final long UNINITIALIZED_LONG = -1L; + // No legitimate float is negative + private static final float UNINITIALIZED_FLOAT = -1.0f; + + 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<PointerProperties[]> mPointerPropertiesArrays + = new ArrayList<PointerProperties[]>(); + final ArrayList<PointerCoords[]> mPointerCoordsArrays = new ArrayList<PointerCoords[]>(); + final ArrayList<Long> mTimes = new ArrayList<Long>(); + } + + /** + * Read motion data from a logStatement and store it in {@code replayData}. + * + * Two kinds of logStatements can be read. In the first variant, the MotionEvent data is + * represented as attributes at the top level like so: + * + * <pre> + * { + * "_ct": 1359590400000, + * "_ut": 4381933, + * "_ty": "MotionEvent", + * "action": "UP", + * "isLoggingRelated": false, + * "x": 100, + * "y": 200 + * } + * </pre> + * + * In the second variant, there is a separate attribute for the MotionEvent that includes + * historical data if present: + * + * <pre> + * { + * "_ct": 135959040000, + * "_ut": 4382702, + * "_ty": "MotionEvent", + * "action": "MOVE", + * "isLoggingRelated": false, + * "motionEvent": { + * "pointerIds": [ + * 0 + * ], + * "xyt": [ + * { + * "t": 4382551, + * "d": [ + * { + * "x": 141.25, + * "y": 151.8485107421875, + * "toma": 101.82337188720703, + * "tomi": 101.82337188720703, + * "o": 0.0 + * } + * ] + * }, + * { + * "t": 4382559, + * "d": [ + * { + * "x": 140.7266082763672, + * "y": 151.8485107421875, + * "toma": 101.82337188720703, + * "tomi": 101.82337188720703, + * "o": 0.0 + * } + * ] + * } + * ] + * } + * }, + * </pre> + */ + /* package for test */ void readLogStatement(final JsonReader jsonReader, + final ReplayData replayData) throws IOException { + String logStatementType = null; + int actionType = UNINITIALIZED_ACTION; + int x = UNINITIALIZED_INT; + int y = UNINITIALIZED_INT; + long time = UNINITIALIZED_LONG; + boolean isLoggingRelated = 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")) { + isLoggingRelated = jsonReader.nextBoolean(); + } else if (logStatementType != null && logStatementType.equals("MotionEvent") + && key.equals("motionEvent")) { + if (actionType == UNINITIALIZED_ACTION) { + Log.e(TAG, "no actionType assigned in MotionEvent json"); + } + // Second variant of LogStatement. + if (isLoggingRelated) { + jsonReader.skipValue(); + } else { + readEmbeddedMotionEvent(jsonReader, replayData, actionType); + } + } else { + if (DEBUG) { + Log.w(TAG, "Unknown JSON key in LogStatement: " + key); + } + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + + if (logStatementType != null && time != UNINITIALIZED_LONG && x != UNINITIALIZED_INT + && y != UNINITIALIZED_INT && actionType != UNINITIALIZED_ACTION + && logStatementType.equals("MotionEvent") && !isLoggingRelated) { + // First variant of LogStatement. + final PointerProperties pointerProperties = new PointerProperties(); + pointerProperties.id = 0; + pointerProperties.toolType = MotionEvent.TOOL_TYPE_UNKNOWN; + final PointerProperties[] pointerPropertiesArray = { + pointerProperties + }; + final PointerCoords pointerCoords = new PointerCoords(); + pointerCoords.x = x; + pointerCoords.y = y; + pointerCoords.pressure = 1.0f; + pointerCoords.size = 1.0f; + final PointerCoords[] pointerCoordsArray = { + pointerCoords + }; + addMotionEventData(replayData, actionType, time, pointerPropertiesArray, + pointerCoordsArray); + } + } + + private void readEmbeddedMotionEvent(final JsonReader jsonReader, final ReplayData replayData, + final int actionType) throws IOException { + jsonReader.beginObject(); + PointerProperties[] pointerPropertiesArray = null; + while (jsonReader.hasNext()) { // pointerIds/xyt + final String name = jsonReader.nextName(); + if (name.equals("pointerIds")) { + pointerPropertiesArray = readPointerProperties(jsonReader); + } else if (name.equals("xyt")) { + readPointerData(jsonReader, replayData, actionType, pointerPropertiesArray); + } + } + jsonReader.endObject(); + } + + private PointerProperties[] readPointerProperties(final JsonReader jsonReader) + throws IOException { + final ArrayList<PointerProperties> pointerPropertiesArrayList = + new ArrayList<PointerProperties>(); + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + final PointerProperties pointerProperties = new PointerProperties(); + pointerProperties.id = jsonReader.nextInt(); + pointerProperties.toolType = MotionEvent.TOOL_TYPE_UNKNOWN; + pointerPropertiesArrayList.add(pointerProperties); + } + jsonReader.endArray(); + return pointerPropertiesArrayList.toArray( + new PointerProperties[pointerPropertiesArrayList.size()]); + } + + private void readPointerData(final JsonReader jsonReader, final ReplayData replayData, + final int actionType, final PointerProperties[] pointerPropertiesArray) + throws IOException { + if (pointerPropertiesArray == null) { + Log.e(TAG, "PointerIDs must be given before xyt data in json for MotionEvent"); + jsonReader.skipValue(); + return; + } + long time = UNINITIALIZED_LONG; + jsonReader.beginArray(); + while (jsonReader.hasNext()) { // Array of historical data + jsonReader.beginObject(); + final ArrayList<PointerCoords> pointerCoordsArrayList = new ArrayList<PointerCoords>(); + while (jsonReader.hasNext()) { // Time/data object + final String name = jsonReader.nextName(); + if (name.equals("t")) { + time = jsonReader.nextLong(); + } else if (name.equals("d")) { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { // array of data per pointer + final PointerCoords pointerCoords = readPointerCoords(jsonReader); + if (pointerCoords != null) { + pointerCoordsArrayList.add(pointerCoords); + } + } + jsonReader.endArray(); + } else { + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + // Data was recorded as historical events, but must be split apart into + // separate MotionEvents for replaying + if (time != UNINITIALIZED_LONG) { + addMotionEventData(replayData, actionType, time, pointerPropertiesArray, + pointerCoordsArrayList.toArray( + new PointerCoords[pointerCoordsArrayList.size()])); + } else { + Log.e(TAG, "Time not assigned in json for MotionEvent"); + } + } + jsonReader.endArray(); + } + + private PointerCoords readPointerCoords(final JsonReader jsonReader) throws IOException { + jsonReader.beginObject(); + float x = UNINITIALIZED_FLOAT; + float y = UNINITIALIZED_FLOAT; + while (jsonReader.hasNext()) { // x,y + final String name = jsonReader.nextName(); + if (name.equals("x")) { + x = (float) jsonReader.nextDouble(); + } else if (name.equals("y")) { + y = (float) jsonReader.nextDouble(); + } else { + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + + if (Float.compare(x, UNINITIALIZED_FLOAT) == 0 + || Float.compare(y, UNINITIALIZED_FLOAT) == 0) { + Log.w(TAG, "missing x or y value in MotionEvent json"); + return null; + } + final PointerCoords pointerCoords = new PointerCoords(); + pointerCoords.x = x; + pointerCoords.y = y; + pointerCoords.pressure = 1.0f; + pointerCoords.size = 1.0f; + return pointerCoords; + } + + /** + * Tests that {@code x} is uninitialized. + * + * Assumes that {@code x} will never be given a valid value less than 0, and that + * UNINITIALIZED_FLOAT is less than 0.0f. + */ + private boolean isUninitializedFloat(final float x) { + return x < 0.0f; + } + + private void addMotionEventData(final ReplayData replayData, final int actionType, + final long time, final PointerProperties[] pointerProperties, + final PointerCoords[] pointerCoords) { + replayData.mActions.add(actionType); + replayData.mTimes.add(time); + replayData.mPointerPropertiesArrays.add(pointerProperties); + replayData.mPointerCoordsArrays.add(pointerCoords); + } +} 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..a9b7a9d0c --- /dev/null +++ b/java/src/com/android/inputmethod/research/Replayer.java @@ -0,0 +1,149 @@ +/* + * 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.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; +import android.view.MotionEvent.PointerCoords; +import android.view.MotionEvent.PointerProperties; + +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; + + private Replayer() { + } + + private static final Replayer sInstance = new Replayer(); + public static Replayer getInstance() { + return sInstance; + } + + 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, final Runnable callback) { + 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(Looper.getMainLooper()) { + // 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 PointerProperties[] pointerPropertiesArray = + replayData.mPointerPropertiesArrays.get(index); + final PointerCoords[] pointerCoordsArray = + replayData.mPointerCoordsArrays.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, + pointerPropertiesArray.length, pointerPropertiesArray, + pointerCoordsArray, 0, 0, 1.0f, 1.0f, 0, 0, 0, 0); + mainKeyboardView.processMotionEvent(me); + me.recycle(); + break; + case MSG_DONE: + mIsReplaying = false; + ResearchLogger.getInstance().requestIndicatorRedraw(); + break; + } + } + }; + + handler.post(new Runnable() { + @Override + public void run() { + ResearchLogger.getInstance().requestIndicatorRedraw(); + } + }); + 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); + if (callback != null) { + handler.postAtTime(callback, presentDoneTime + 1); + } + } + + public boolean isReplaying() { + return mIsReplaying; + } +} diff --git a/java/src/com/android/inputmethod/research/ReplayerService.java b/java/src/com/android/inputmethod/research/ReplayerService.java new file mode 100644 index 000000000..88d9033cf --- /dev/null +++ b/java/src/com/android/inputmethod/research/ReplayerService.java @@ -0,0 +1,65 @@ +/* + * 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.app.IntentService; +import android.content.Intent; +import android.util.Log; + +import com.android.inputmethod.research.MotionEventReader.ReplayData; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +/** + * Provide a mechanism to invoke the replayer from outside. + * + * In particular, makes access from a host possible through {@code adb am startservice}. + */ +public class ReplayerService extends IntentService { + private static final String TAG = ReplayerService.class.getSimpleName(); + private static final String EXTRA_FILENAME = "com.android.inputmethod.research.extra.FILENAME"; + private static final long MAX_REPLAY_TIME = TimeUnit.SECONDS.toMillis(60); + + public ReplayerService() { + super(ReplayerService.class.getSimpleName()); + } + + @Override + protected void onHandleIntent(final Intent intent) { + final String filename = intent.getStringExtra(EXTRA_FILENAME); + if (filename == null) return; + + final ReplayData replayData = new MotionEventReader().readMotionEventData( + new File(filename)); + synchronized (this) { + Replayer.getInstance().replay(replayData, new Runnable() { + @Override + public void run() { + synchronized (ReplayerService.this) { + ReplayerService.this.notify(); + } + } + }); + try { + wait(MAX_REPLAY_TIME); + } catch (InterruptedException e) { + Log.e(TAG, "Timeout while replaying.", e); + } + } + } +} diff --git a/java/src/com/android/inputmethod/research/ResearchLog.java b/java/src/com/android/inputmethod/research/ResearchLog.java index 70c38e909..99d84938f 100644 --- a/java/src/com/android/inputmethod/research/ResearchLog.java +++ b/java/src/com/android/inputmethod/research/ResearchLog.java @@ -1,39 +1,33 @@ /* * Copyright (C) 2012 The Android Open Source Project * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at + * 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 + * 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. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.android.inputmethod.research; -import android.content.SharedPreferences; -import android.os.SystemClock; +import android.content.Context; import android.util.JsonWriter; import android.util.Log; -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.define.ProductionFlag; import java.io.BufferedWriter; import java.io.File; -import java.io.FileWriter; +import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; -import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; @@ -51,12 +45,14 @@ import java.util.concurrent.TimeUnit; */ public class ResearchLog { private static final String TAG = ResearchLog.class.getSimpleName(); - private static final boolean DEBUG = false; + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; private static final long FLUSH_DELAY_IN_MS = 1000 * 5; private static final int ABORT_TIMEOUT_IN_MS = 1000 * 4; /* package */ final ScheduledExecutorService mExecutor; /* package */ final File mFile; + private final Context mContext; + private JsonWriter mJsonWriter = NULL_JSON_WRITER; // true if at least one byte of data has been written out to the log file. This must be // remembered because JsonWriter requires that calls matching calls to beginObject and @@ -85,12 +81,10 @@ public class ResearchLog { } } - public ResearchLog(final File outputFile) { - if (outputFile == null) { - throw new IllegalArgumentException(); - } + public ResearchLog(final File outputFile, final Context context) { mExecutor = Executors.newSingleThreadScheduledExecutor(); mFile = outputFile; + mContext = context; } public synchronized void close(final Runnable onClosed) { @@ -115,7 +109,7 @@ public class ResearchLog { Log.d(TAG, "error when closing ResearchLog:"); e.printStackTrace(); } finally { - if (mFile.exists()) { + if (mFile != null && mFile.exists()) { mFile.setWritable(false, false); } if (onClosed != null) { @@ -142,7 +136,9 @@ public class ResearchLog { mHasWrittenData = false; } } finally { - mIsAbortSuccessful = mFile.delete(); + if (mFile != null) { + mIsAbortSuccessful = mFile.delete(); + } } return null; } @@ -188,133 +184,61 @@ public class ResearchLog { mFlushFuture = mExecutor.schedule(mFlushCallable, FLUSH_DELAY_IN_MS, TimeUnit.MILLISECONDS); } - public synchronized void publish(final LogUnit logUnit, final boolean isIncludingPrivateData) { + public synchronized void publish(final LogUnit logUnit, final boolean canIncludePrivateData) { try { mExecutor.submit(new Callable<Object>() { @Override public Object call() throws Exception { - logUnit.publishTo(ResearchLog.this, isIncludingPrivateData); + logUnit.publishTo(ResearchLog.this, canIncludePrivateData); scheduleFlush(); return null; } }); } catch (RejectedExecutionException e) { // TODO: Add code to record loss of data, and report. + if (DEBUG) { + Log.d(TAG, "ResearchLog.publish() rejecting scheduled execution"); + } } } - private static final String CURRENT_TIME_KEY = "_ct"; - private static final String UPTIME_KEY = "_ut"; - private static final String EVENT_TYPE_KEY = "_ty"; - - void outputEvent(final String[] keys, final Object[] values) { - // Not thread safe. - if (keys.length == 0) { - return; - } - if (DEBUG) { - if (keys.length != values.length + 1) { - Log.d(TAG, "Key and Value list sizes do not match. " + keys[0]); - } - } + /** + * Return a JsonWriter for this ResearchLog. It is initialized the first time this method is + * called. The cached value is returned in future calls. + */ + public JsonWriter getInitializedJsonWriterLocked() { + if (mJsonWriter != NULL_JSON_WRITER || mFile == null) return mJsonWriter; try { - if (mJsonWriter == NULL_JSON_WRITER) { - mJsonWriter = new JsonWriter(new BufferedWriter(new FileWriter(mFile))); - mJsonWriter.beginArray(); + final JsonWriter jsonWriter = createJsonWriter(mContext, mFile); + if (jsonWriter != null) { + jsonWriter.beginArray(); + mJsonWriter = jsonWriter; mHasWrittenData = true; } - mJsonWriter.beginObject(); - mJsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis()); - mJsonWriter.name(UPTIME_KEY).value(SystemClock.uptimeMillis()); - mJsonWriter.name(EVENT_TYPE_KEY).value(keys[0]); - final int length = values.length; - for (int i = 0; i < length; i++) { - mJsonWriter.name(keys[i + 1]); - Object value = values[i]; - if (value instanceof CharSequence) { - mJsonWriter.value(value.toString()); - } else if (value instanceof Number) { - mJsonWriter.value((Number) value); - } else if (value instanceof Boolean) { - mJsonWriter.value((Boolean) value); - } else if (value instanceof CompletionInfo[]) { - CompletionInfo[] ci = (CompletionInfo[]) value; - mJsonWriter.beginArray(); - for (int j = 0; j < ci.length; j++) { - mJsonWriter.value(ci[j].toString()); - } - mJsonWriter.endArray(); - } else if (value instanceof SharedPreferences) { - SharedPreferences prefs = (SharedPreferences) value; - mJsonWriter.beginObject(); - for (Map.Entry<String,?> entry : prefs.getAll().entrySet()) { - mJsonWriter.name(entry.getKey()); - final Object innerValue = entry.getValue(); - if (innerValue == null) { - mJsonWriter.nullValue(); - } else if (innerValue instanceof Boolean) { - mJsonWriter.value((Boolean) innerValue); - } else if (innerValue instanceof Number) { - mJsonWriter.value((Number) innerValue); - } else { - mJsonWriter.value(innerValue.toString()); - } - } - mJsonWriter.endObject(); - } else if (value instanceof Key[]) { - Key[] keyboardKeys = (Key[]) value; - mJsonWriter.beginArray(); - for (Key keyboardKey : keyboardKeys) { - mJsonWriter.beginObject(); - mJsonWriter.name("code").value(keyboardKey.mCode); - mJsonWriter.name("altCode").value(keyboardKey.getAltCode()); - mJsonWriter.name("x").value(keyboardKey.mX); - mJsonWriter.name("y").value(keyboardKey.mY); - mJsonWriter.name("w").value(keyboardKey.mWidth); - mJsonWriter.name("h").value(keyboardKey.mHeight); - mJsonWriter.endObject(); - } - mJsonWriter.endArray(); - } else if (value instanceof SuggestedWords) { - SuggestedWords words = (SuggestedWords) value; - mJsonWriter.beginObject(); - mJsonWriter.name("typedWordValid").value(words.mTypedWordValid); - mJsonWriter.name("willAutoCorrect").value(words.mWillAutoCorrect); - mJsonWriter.name("isPunctuationSuggestions") - .value(words.mIsPunctuationSuggestions); - mJsonWriter.name("isObsoleteSuggestions").value(words.mIsObsoleteSuggestions); - mJsonWriter.name("isPrediction").value(words.mIsPrediction); - mJsonWriter.name("words"); - mJsonWriter.beginArray(); - final int size = words.size(); - for (int j = 0; j < size; j++) { - SuggestedWordInfo wordInfo = words.getWordInfo(j); - mJsonWriter.value(wordInfo.toString()); - } - mJsonWriter.endArray(); - mJsonWriter.endObject(); - } else if (value == null) { - mJsonWriter.nullValue(); - } else { - Log.w(TAG, "Unrecognized type to be logged: " + - (value == null ? "<null>" : value.getClass().getName())); - mJsonWriter.nullValue(); - } - } - mJsonWriter.endObject(); - } catch (IOException e) { - e.printStackTrace(); - Log.w(TAG, "Error in JsonWriter; disabling logging"); + } catch (final IOException e) { + Log.w(TAG, "Error in JsonWriter; disabling logging", e); try { mJsonWriter.close(); - } catch (IllegalStateException e1) { + } catch (final IllegalStateException e1) { // Assume that this is just the json not being terminated properly. // Ignore - } catch (IOException e1) { - e1.printStackTrace(); + } catch (final IOException e1) { + Log.w(TAG, "Error in closing JsonWriter; disabling logging", e1); } finally { mJsonWriter = NULL_JSON_WRITER; } } + return mJsonWriter; + } + + /** + * Create the JsonWriter to write the ResearchLog to. + * + * This method may be overriden in testing to redirect the output. + */ + /* package for test */ JsonWriter createJsonWriter(final Context context, final File file) + throws IOException { + return new JsonWriter(new BufferedWriter(new OutputStreamWriter( + context.openFileOutput(file.getName(), Context.MODE_PRIVATE)))); } } diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java index 763fd6e00..25633d630 100644 --- a/java/src/com/android/inputmethod/research/ResearchLogger.java +++ b/java/src/com/android/inputmethod/research/ResearchLogger.java @@ -1,23 +1,25 @@ /* * 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 + * 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 + * 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. + * 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 static com.android.inputmethod.latin.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET; +import android.accounts.Account; +import android.accounts.AccountManager; import android.app.AlarmManager; import android.app.AlertDialog; import android.app.Dialog; @@ -30,15 +32,18 @@ import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.content.pm.PackageInfo; import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.Style; -import android.inputmethodservice.InputMethodService; import android.net.Uri; import android.os.Build; +import android.os.Bundle; +import android.os.Handler; import android.os.IBinder; import android.os.SystemClock; +import android.preference.PreferenceManager; import android.text.TextUtils; import android.text.format.DateUtils; import android.util.Log; @@ -47,7 +52,6 @@ import android.view.MotionEvent; import android.view.Window; import android.view.WindowManager; import android.view.inputmethod.CompletionInfo; -import android.view.inputmethod.CorrectionInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.Toast; @@ -55,11 +59,12 @@ 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.CollectionUtils; import com.android.inputmethod.latin.Constants; import com.android.inputmethod.latin.Dictionary; +import com.android.inputmethod.latin.InputTypeUtils; import com.android.inputmethod.latin.LatinIME; import com.android.inputmethod.latin.R; import com.android.inputmethod.latin.RichInputConnection; @@ -67,11 +72,23 @@ import com.android.inputmethod.latin.RichInputConnection.Range; import com.android.inputmethod.latin.Suggest; import com.android.inputmethod.latin.SuggestedWords; import com.android.inputmethod.latin.define.ProductionFlag; +import com.android.inputmethod.research.MotionEventReader.ReplayData; +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.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; +import java.util.List; import java.util.Locale; +import java.util.Random; import java.util.UUID; /** @@ -83,28 +100,66 @@ import java.util.UUID; * This functionality is off by default. See {@link ProductionFlag#IS_EXPERIMENTAL}. */ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChangeListener { + // TODO: This class has grown quite large and combines several concerns that should be + // separated. The following refactorings will be applied as soon as possible after adding + // support for replaying historical events, fixing some replay bugs, adding some ui constraints + // on the feedback dialog, and adding the survey dialog. + // TODO: Refactor. Move splash screen code into separate class. + // TODO: Refactor. Move feedback screen code into separate class. + // TODO: Refactor. Move logging invocations into their own class. + // TODO: Refactor. Move currentLogUnit management into separate class. private static final String TAG = ResearchLogger.class.getSimpleName(); - private static final boolean DEBUG = false; - private static final boolean OUTPUT_ENTIRE_BUFFER = false; // true may disclose private info + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + private static final boolean DEBUG_REPLAY_AFTER_FEEDBACK = false + && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + // Whether the TextView contents are logged at the end of the session. true will disclose + // private info. + private static final boolean LOG_FULL_TEXTVIEW_CONTENTS = false + && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + // Whether the feedback dialog preserves the editable text across invocations. Should be false + // for normal research builds so users do not have to delete the same feedback string they + // entered earlier. Should be true for builds internal to a development team so when the text + // field holds a channel name, the developer does not have to re-enter it when using the + // feedback mechanism to generate multiple tests. + private static final boolean FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD = false; public static final boolean DEFAULT_USABILITY_STUDY_MODE = false; /* package */ static boolean sIsLogging = false; - private static final int OUTPUT_FORMAT_VERSION = 1; + 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 all words should be recorded, leaving unsampled word between bigrams. Useful for + // testing. + /* package for test */ static final boolean IS_LOGGING_EVERYTHING = false + && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + // The number of words between n-grams to omit from the log. + private static final int NUMBER_OF_WORDS_BETWEEN_SAMPLES = + IS_LOGGING_EVERYTHING ? 0 : (DEBUG ? 2 : 18); + + // Whether to show an indicator on the screen that logging is on. Currently a very small red + // dot in the lower right hand corner. Most users should not notice it. private static final boolean IS_SHOWING_INDICATOR = true; - private static final boolean IS_SHOWING_INDICATOR_CLEARLY = false; - public static final int FEEDBACK_WORD_BUFFER_SIZE = 5; + // Change the default indicator to something very visible. Currently two red vertical bars on + // either side of they keyboard. + private static final boolean IS_SHOWING_INDICATOR_CLEARLY = false || + (IS_LOGGING_EVERYTHING && ProductionFlag.IS_EXPERIMENTAL_DEBUG); + // FEEDBACK_WORD_BUFFER_SIZE should add 1 because it must also hold the feedback LogUnit itself. + public static final int FEEDBACK_WORD_BUFFER_SIZE = (Integer.MAX_VALUE - 1) + 1; // constants related to specific log points private static final String WHITESPACE_SEPARATORS = " \t\n\r"; private static final int MAX_INPUTVIEW_LENGTH_TO_CAPTURE = 8192; // must be >=1 private static final String PREF_RESEARCH_LOGGER_UUID_STRING = "pref_research_logger_uuid"; + private static final String PREF_RESEARCH_SAVED_CHANNEL = "pref_research_saved_channel"; private static final ResearchLogger sInstance = new ResearchLogger(); + private static String sAccountType = null; + private static String sAllowedAccountDomain = null; // to write to a different filename, e.g., for testing, set mFile before calling start() /* package */ File mFilesDir; /* package */ String mUUIDString; @@ -114,9 +169,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; @@ -129,7 +190,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang /* package for test */ static final String WORD_REPLACEMENT_STRING = "\uE001"; private static final String PREF_LAST_CLEANUP_TIME = "pref_last_cleanup_time"; private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS; - private static final long MAX_LOGFILE_AGE_IN_MS = DateUtils.DAY_IN_MILLIS; + private static final long MAX_LOGFILE_AGE_IN_MS = 4 * DateUtils.DAY_IN_MILLIS; protected static final int SUSPEND_DURATION_IN_MINUTES = 1; // set when LatinIME should ignore an onUpdateSelection() callback that // arises from operations in this class @@ -137,16 +198,37 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang // used to check whether words are not unique private Suggest mSuggest; - private Dictionary mDictionary; private MainKeyboardView mMainKeyboardView; - private InputMethodService mInputMethodService; + // TODO: Check whether a superclass can be used instead of LatinIME. + /* package for test */ LatinIME mLatinIME; private final Statistics mStatistics; + private final MotionEventReader mMotionEventReader = new MotionEventReader(); + private final Replayer mReplayer = Replayer.getInstance(); private Intent mUploadIntent; - private PendingIntent mUploadPendingIntent; + private Intent mUploadNowIntent; private LogUnit mCurrentLogUnit = new LogUnit(); + // Gestured or tapped words may be committed after the gesture of the next word has started. + // To ensure that the gesture data of the next word is not associated with the previous word, + // thereby leaking private data, we store the time of the down event that started the second + // gesture, and when committing the earlier word, split the LogUnit. + private long mSavedDownEventTime; + private Bundle mFeedbackDialogBundle = null; + private boolean mInFeedbackDialog = false; + // The feedback dialog causes stop() to be called for the keyboard connected to the original + // window. This is because the feedback dialog must present its own EditText box that displays + // a keyboard. stop() normally causes mFeedbackLogBuffer, which contains the user's data, to be + // cleared, and causes mFeedbackLog, which is ready to collect information in case the user + // wants to upload, to be closed. This is good because we don't need to log information about + // what the user is typing in the feedback dialog, but bad because this data must be uploaded. + // Here we save the LogBuffer and Log so the feedback dialog can later access their data. + private LogBuffer mSavedFeedbackLogBuffer; + private ResearchLog mSavedFeedbackLog; + private Handler mUserRecordingTimeoutHandler; + private static final long USER_RECORDING_TIMEOUT_MS = 30L * DateUtils.SECOND_IN_MILLIS; + private ResearchLogger() { mStatistics = Statistics.getInstance(); } @@ -155,16 +237,17 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang return sInstance; } - public void init(final InputMethodService ims, final SharedPreferences prefs) { - assert ims != null; - if (ims == null) { + public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) { + assert latinIME != null; + if (latinIME == null) { Log.w(TAG, "IMS is null; logging is off"); } else { - mFilesDir = ims.getFilesDir(); + mFilesDir = latinIME.getFilesDir(); if (mFilesDir == null || !mFilesDir.exists()) { Log.w(TAG, "IME storage directory does not exist."); } } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(latinIME); if (prefs != null) { mUUIDString = getUUID(prefs); if (!prefs.contains(PREF_USABILITY_STUDY_MODE)) { @@ -185,13 +268,18 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang e.apply(); } } - mInputMethodService = ims; + final Resources res = latinIME.getResources(); + sAccountType = res.getString(R.string.research_account_type); + sAllowedAccountDomain = res.getString(R.string.research_allowed_account_domain); + mLatinIME = latinIME; mPrefs = prefs; - mUploadIntent = new Intent(mInputMethodService, UploaderService.class); - mUploadPendingIntent = PendingIntent.getService(mInputMethodService, 0, mUploadIntent, 0); + mUploadIntent = new Intent(mLatinIME, UploaderService.class); + mUploadNowIntent = new Intent(mLatinIME, UploaderService.class); + mUploadNowIntent.putExtra(UploaderService.EXTRA_UPLOAD_UNCONDITIONALLY, true); + mReplayer.setKeyboardSwitcher(keyboardSwitcher); if (ProductionFlag.IS_EXPERIMENTAL) { - scheduleUploadingService(mInputMethodService); + scheduleUploadingService(mLatinIME); } } @@ -216,8 +304,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(); } } @@ -250,7 +340,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang if (windowToken == null) { return; } - final AlertDialog.Builder builder = new AlertDialog.Builder(mInputMethodService) + final AlertDialog.Builder builder = new AlertDialog.Builder(mLatinIME) .setTitle(R.string.research_splash_title) .setMessage(R.string.research_splash_content) .setPositiveButton(android.R.string.yes, @@ -265,12 +355,12 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - final String packageName = mInputMethodService.getPackageName(); + final String packageName = mLatinIME.getPackageName(); final Uri packageUri = Uri.parse("package:" + packageName); final Intent intent = new Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - mInputMethodService.startActivity(intent); + mLatinIME.startActivity(intent); } }) .setCancelable(true) @@ -278,7 +368,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang new OnCancelListener() { @Override public void onCancel(DialogInterface dialog) { - mInputMethodService.requestHideSelf(0); + mLatinIME.requestHideSelf(0); } }); mSplashDialog = builder.create(); @@ -312,20 +402,40 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang sIsLogging = enableLogging; } - private File createLogFile(File filesDir) { + private static int sLogFileCounter = 0; + + 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 + // separate these. + if (sLogFileCounter < Integer.MAX_VALUE) { + sLogFileCounter++; + } else { + // Wrap the counter, in the unlikely event of overflow. + sLogFileCounter = 0; + } + sb.append(sLogFileCounter); + sb.append(LOG_FILENAME_SUFFIX); + return new File(filesDir, sb.toString()); + } + + private File createUserRecordingFile(final File filesDir) { + final StringBuilder sb = new StringBuilder(); + sb.append(USER_RECORDING_FILENAME_PREFIX).append('-'); sb.append(mUUIDString).append('-'); sb.append(TIMESTAMP_DATEFORMAT.format(new Date())); - sb.append(FILENAME_SUFFIX); + sb.append(USER_RECORDING_FILENAME_SUFFIX); return new File(filesDir, sb.toString()); } private void checkForEmptyEditor() { - if (mInputMethodService == null) { + if (mLatinIME == null) { return; } - final InputConnection ic = mInputMethodService.getCurrentInputConnection(); + final InputConnection ic = mLatinIME.getCurrentInputConnection(); if (ic == null) { return; } @@ -362,27 +472,56 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang return; } if (mMainLogBuffer == null) { - mMainResearchLog = new ResearchLog(createLogFile(mFilesDir)); - mMainLogBuffer = new MainLogBuffer(mMainResearchLog); + mMainResearchLog = new ResearchLog(createLogFile(mFilesDir), mLatinIME); + final int numWordsToIgnore = new Random().nextInt(NUMBER_OF_WORDS_BETWEEN_SAMPLES + 1); + mMainLogBuffer = new MainLogBuffer(NUMBER_OF_WORDS_BETWEEN_SAMPLES, numWordsToIgnore) { + @Override + protected void publish(final ArrayList<LogUnit> logUnits, + boolean canIncludePrivateData) { + canIncludePrivateData |= IS_LOGGING_EVERYTHING; + final int length = logUnits.size(); + for (int i = 0; i < length; i++) { + final LogUnit logUnit = logUnits.get(i); + final String word = logUnit.getWord(); + if (word != null && word.length() > 0 && hasLetters(word)) { + Log.d(TAG, "onPublish: " + word + ", hc: " + + logUnit.containsCorrection()); + final Dictionary dictionary = getDictionary(); + mStatistics.recordWordEntered( + dictionary != null && dictionary.isValidWord(word), + logUnit.containsCorrection()); + } + } + if (mMainResearchLog != null) { + publishLogUnits(logUnits, mMainResearchLog, canIncludePrivateData); + } + } + }; mMainLogBuffer.setSuggest(mSuggest); } if (mFeedbackLogBuffer == null) { - mFeedbackLog = new ResearchLog(createLogFile(mFilesDir)); - // LogBuffer is one more than FEEDBACK_WORD_BUFFER_SIZE, because it must also hold - // the feedback LogUnit itself. - mFeedbackLogBuffer = new LogBuffer(FEEDBACK_WORD_BUFFER_SIZE + 1); + resetFeedbackLogging(); } } + private void resetFeedbackLogging() { + mFeedbackLog = new ResearchLog(createLogFile(mFilesDir), mLatinIME); + mFeedbackLogBuffer = new FixedLogBuffer(FEEDBACK_WORD_BUFFER_SIZE); + } + /* package */ void stop() { if (DEBUG) { Log.d(TAG, "stop called"); } - logStatistics(); + // Commit mCurrentLogUnit before closing. commitCurrentLogUnit(); if (mMainLogBuffer != null) { - publishLogBuffer(mMainLogBuffer, mMainResearchLog, false /* isIncludingPrivateData */); + mMainLogBuffer.shiftAndPublishAll(); + logStatistics(); + commitCurrentLogUnit(); + mMainLogBuffer.setIsStopping(); + mMainLogBuffer.shiftAndPublishAll(); mMainResearchLog.close(null /* callback */); mMainLogBuffer = null; } @@ -427,21 +566,6 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } private long mResumeTime = 0L; - private void suspendLoggingUntil(long time) { - mIsLoggingSuspended = true; - mResumeTime = time; - requestIndicatorRedraw(); - } - - private void resumeLogging() { - mResumeTime = 0L; - updateSuspendedState(); - requestIndicatorRedraw(); - if (isAllowedToLog()) { - restart(); - } - } - private void updateSuspendedState() { final long time = System.currentTimeMillis(); if (time > mResumeTime) { @@ -472,42 +596,108 @@ 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; - } + public void presentFeedbackDialog(LatinIME latinIME) { + if (isMakingUserRecording()) { + saveRecording(); + } + mInFeedbackDialog = true; + mSavedFeedbackLogBuffer = mFeedbackLogBuffer; + mSavedFeedbackLog = mFeedbackLog; + // Set the non-saved versions to null so that the stop() caused by switching to the + // Feedback dialog will not close them. + mFeedbackLogBuffer = null; + mFeedbackLog = null; + + final Intent intent = new Intent(); + intent.setClass(mLatinIME, FeedbackActivity.class); + if (mFeedbackDialogBundle == null) { + // Restore feedback field with channel name + final Bundle bundle = new Bundle(); + bundle.putBoolean(FeedbackFragment.KEY_INCLUDE_ACCOUNT_NAME, true); + bundle.putBoolean(FeedbackFragment.KEY_HAS_USER_RECORDING, false); + if (FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD) { + final String savedChannelName = mPrefs.getString(PREF_RESEARCH_SAVED_CHANNEL, ""); + bundle.putString(FeedbackFragment.KEY_FEEDBACK_STRING, savedChannelName); } + mFeedbackDialogBundle = bundle; + } + intent.putExtras(mFeedbackDialogBundle); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + latinIME.startActivity(intent); + } - }; - final AlertDialog.Builder builder = new AlertDialog.Builder(latinIME) - .setItems(items, listener) - .setTitle(title); - latinIME.showOptionDialog(builder.create()); + public void setFeedbackDialogBundle(final Bundle bundle) { + mFeedbackDialogBundle = bundle; } - */ - private boolean mInFeedbackDialog = false; - public void presentFeedbackDialog(LatinIME latinIME) { - mInFeedbackDialog = true; - latinIME.launchKeyboardedDialogActivity(FeedbackActivity.class); + public void startRecording() { + final Resources res = mLatinIME.getResources(); + Toast.makeText(mLatinIME, + res.getString(R.string.research_feedback_demonstration_instructions), + Toast.LENGTH_LONG).show(); + startRecordingInternal(); + } + + private void startRecordingInternal() { + if (mUserRecordingLog != null) { + mUserRecordingLog.abort(); + } + mUserRecordingFile = createUserRecordingFile(mFilesDir); + mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME); + mUserRecordingLogBuffer = new LogBuffer(); + resetRecordingTimer(); + } + + private boolean isMakingUserRecording() { + return mUserRecordingLog != null; + } + + private void resetRecordingTimer() { + if (mUserRecordingTimeoutHandler == null) { + mUserRecordingTimeoutHandler = new Handler(); + } + clearRecordingTimer(); + mUserRecordingTimeoutHandler.postDelayed(mRecordingHandlerTimeoutRunnable, + USER_RECORDING_TIMEOUT_MS); + } + + private void clearRecordingTimer() { + mUserRecordingTimeoutHandler.removeCallbacks(mRecordingHandlerTimeoutRunnable); + } + + private Runnable mRecordingHandlerTimeoutRunnable = new Runnable() { + @Override + public void run() { + cancelRecording(); + requestIndicatorRedraw(); + final Resources res = mLatinIME.getResources(); + Toast.makeText(mLatinIME, res.getString(R.string.research_feedback_recording_failure), + Toast.LENGTH_LONG).show(); + } + }; + + private void cancelRecording() { + if (mUserRecordingLog != null) { + mUserRecordingLog.abort(); + } + mUserRecordingLog = null; + mUserRecordingLogBuffer = null; + if (mFeedbackDialogBundle != null) { + mFeedbackDialogBundle.putBoolean("HasRecording", false); + } + } + + private void saveRecording() { + commitCurrentLogUnit(); + publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true); + mUserRecordingLog.close(null); + mUserRecordingLog = null; + mUserRecordingLogBuffer = null; + + if (mFeedbackDialogBundle != null) { + mFeedbackDialogBundle.putBoolean(FeedbackFragment.KEY_HAS_USER_RECORDING, true); + } + clearRecordingTimer(); } // TODO: currently unreachable. Remove after being sure enable/disable is @@ -539,40 +729,116 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } */ - private static final String[] EVENTKEYS_FEEDBACK = { - "UserTimestamp", "contents" - }; - public void sendFeedback(final String feedbackContents, final boolean includeHistory) { - if (mFeedbackLogBuffer == null) { + /** + * Get the name of the first allowed account on the device. + * + * Allowed accounts must be in the domain given by ALLOWED_ACCOUNT_DOMAIN. + * + * @return The user's account name. + */ + public String getAccountName() { + if (sAccountType == null || sAccountType.isEmpty()) { + return null; + } + if (sAllowedAccountDomain == null || sAllowedAccountDomain.isEmpty()) { + return null; + } + final AccountManager manager = AccountManager.get(mLatinIME); + // Filter first by account type. + final Account[] accounts = manager.getAccountsByType(sAccountType); + + for (final Account account : accounts) { + if (DEBUG) { + Log.d(TAG, account.name); + } + final String[] parts = account.name.split("@"); + if (parts.length > 1 && parts[1].equals(sAllowedAccountDomain)) { + return parts[0]; + } + } + return null; + } + + private static final LogStatement LOGSTATEMENT_FEEDBACK = + new LogStatement("UserFeedback", false, false, "contents", "accountName", "recording"); + public void sendFeedback(final String feedbackContents, final boolean includeHistory, + final boolean isIncludingAccountName, final boolean isIncludingRecording) { + if (mSavedFeedbackLogBuffer == null) { return; } - if (includeHistory) { - commitCurrentLogUnit(); - } else { - mFeedbackLogBuffer.clear(); + if (!includeHistory) { + mSavedFeedbackLogBuffer.clear(); + } + String recording = ""; + if (isIncludingRecording) { + // Try to read recording from recently written json file + if (mUserRecordingFile != null) { + FileChannel channel = null; + try { + channel = new FileInputStream(mUserRecordingFile).getChannel(); + final MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, + channel.size()); + // Android's openFileOutput() creates the file, so we use Android's default + // Charset (UTF-8) here to read it. + recording = Charset.defaultCharset().decode(buffer).toString(); + } catch (FileNotFoundException e) { + Log.e(TAG, "Could not find recording file", e); + } catch (IOException e) { + Log.e(TAG, "Error reading recording file", e); + } finally { + if (channel != null) { + try { + channel.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing recording file", e); + } + } + } + } } final LogUnit feedbackLogUnit = new LogUnit(); - final Object[] values = { - feedbackContents - }; - feedbackLogUnit.addLogStatement(EVENTKEYS_FEEDBACK, values, - false /* isPotentiallyPrivate */); + final String accountName = isIncludingAccountName ? getAccountName() : ""; + feedbackLogUnit.addLogStatement(LOGSTATEMENT_FEEDBACK, SystemClock.uptimeMillis(), + feedbackContents, accountName, recording); mFeedbackLogBuffer.shiftIn(feedbackLogUnit); - publishLogBuffer(mFeedbackLogBuffer, mFeedbackLog, true /* isIncludingPrivateData */); - mFeedbackLog.close(new Runnable() { + publishLogBuffer(mFeedbackLogBuffer, mSavedFeedbackLog, true /* isIncludingPrivateData */); + mSavedFeedbackLog.close(new Runnable() { @Override public void run() { uploadNow(); } }); - mFeedbackLog = new ResearchLog(createLogFile(mFilesDir)); + + if (isIncludingRecording && DEBUG_REPLAY_AFTER_FEEDBACK) { + final Handler handler = new Handler(); + handler.postDelayed(new Runnable() { + @Override + public void run() { + final ReplayData replayData = + mMotionEventReader.readMotionEventData(mUserRecordingFile); + mReplayer.replay(replayData, null); + } + }, 1000); + } + + if (FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD) { + // Use feedback string as a channel name to label feedback strings. Here we record the + // string for prepopulating the field next time. + final String channelName = feedbackContents; + if (mPrefs == null) { + return; + } + final Editor e = mPrefs.edit(); + e.putString(PREF_RESEARCH_SAVED_CHANNEL, channelName); + e.apply(); + } } public void uploadNow() { if (DEBUG) { Log.d(TAG, "calling uploadNow()"); } - mInputMethodService.startService(mUploadIntent); + mLatinIME.startService(mUploadNowIntent); } public void onLeavingSendFeedbackDialog() { @@ -586,19 +852,20 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } } + private Dictionary getDictionary() { + if (mSuggest == null) { + return null; + } + return mSuggest.getMainDictionary(); + } + private void setIsPasswordView(boolean isPasswordView) { mIsPasswordView = isPasswordView; } private boolean isAllowedToLog() { - if (DEBUG) { - Log.d(TAG, "iatl: " + - "mipw=" + mIsPasswordView + - ", mils=" + mIsLoggingSuspended + - ", sil=" + sIsLogging + - ", mInFeedbackDialog=" + mInFeedbackDialog); - } - return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog; + return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog + && !isReplaying(); } public void requestIndicatorRedraw() { @@ -611,27 +878,41 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang mMainKeyboardView.invalidateAllKeys(); } + private boolean isReplaying() { + return mReplayer.isReplaying(); + } + + private int getIndicatorColor() { + if (isMakingUserRecording()) { + return Color.YELLOW; + } + if (isReplaying()) { + return Color.GREEN; + } + return Color.RED; + } public void paintIndicator(KeyboardView view, Paint paint, Canvas canvas, int width, int height) { // TODO: Reimplement using a keyboard background image specific to the ResearchLogger // and remove this method. - // The check for MainKeyboardView ensures that a red border is only placed around - // the main keyboard, not every keyboard. - if (IS_SHOWING_INDICATOR && isAllowedToLog() && view instanceof MainKeyboardView) { + // The check for MainKeyboardView ensures that the indicator only decorates the main + // keyboard, not every keyboard. + if (IS_SHOWING_INDICATOR && (isAllowedToLog() || isReplaying()) + && view instanceof MainKeyboardView) { final int savedColor = paint.getColor(); - paint.setColor(Color.RED); + paint.setColor(getIndicatorColor()); final Style savedStyle = paint.getStyle(); paint.setStyle(Style.STROKE); final float savedStrokeWidth = paint.getStrokeWidth(); if (IS_SHOWING_INDICATOR_CLEARLY) { paint.setStrokeWidth(5); - canvas.drawRect(0, 0, width, height, paint); + canvas.drawLine(0, 0, 0, height, paint); + canvas.drawLine(width, 0, width, height, paint); } else { - // Put a tiny red dot on the screen so a knowledgeable user can check whether - // it is enabled. The dot is actually a zero-width, zero-height rectangle, - // placed at the lower-right corner of the canvas, painted with a non-zero border - // width. + // Put a tiny dot on the screen so a knowledgeable user can check whether it is + // enabled. The dot is actually a zero-width, zero-height rectangle, placed at the + // lower-right corner of the canvas, painted with a non-zero border width. paint.setStrokeWidth(3); canvas.drawRect(width, height, width, height, paint); } @@ -641,97 +922,191 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } } - private static final Object[] EVENTKEYS_NULLVALUES = {}; - /** * Buffer a research log event, flagging it as privacy-sensitive. - * - * This 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. - * - * @param keys an array containing a descriptive name for the event, followed by the keys - * @param values an array of values, either a String or Number. length should be one - * less than the keys array */ - private synchronized void enqueuePotentiallyPrivateEvent(final String[] keys, - final Object[] values) { - assert values.length + 1 == keys.length; - if (isAllowedToLog()) { - mCurrentLogUnit.addLogStatement(keys, values, true /* isPotentiallyPrivate */); + private synchronized void enqueueEvent(final LogStatement logStatement, + final Object... values) { + enqueueEvent(mCurrentLogUnit, logStatement, values); + } + + private synchronized void enqueueEvent(final LogUnit logUnit, final LogStatement logStatement, + final Object... values) { + assert values.length == logStatement.getKeys().length; + if (isAllowedToLog() && logUnit != null) { + final long time = SystemClock.uptimeMillis(); + logUnit.addLogStatement(logStatement, time, values); } } private void setCurrentLogUnitContainsDigitFlag() { - mCurrentLogUnit.setContainsDigit(); + mCurrentLogUnit.setMayContainDigit(); } - /** - * Buffer a research log event, flaggint it as not privacy-sensitive. - * - * This event contains no potentially private information. Even if the word that this event - * is privacy-sensitive, this event can still safely be sent to the output log. The system - * waits until the containing word is known so that this event can be written in the proper - * temporal order with other events that may be privacy sensitive. - * - * @param keys an array containing a descriptive name for the event, followed by the keys - * @param values an array of values, either a String or Number. length should be one - * less than the keys array - */ - private synchronized void enqueueEvent(final String[] keys, final Object[] values) { - assert values.length + 1 == keys.length; - if (isAllowedToLog()) { - mCurrentLogUnit.addLogStatement(keys, values, false /* isPotentiallyPrivate */); - } + private void setCurrentLogUnitContainsCorrection() { + mCurrentLogUnit.setContainsCorrection(); + } + + private void setCurrentLogUnitCorrectionType(final int correctionType) { + mCurrentLogUnit.setCorrectionType(correctionType); } /* package for test */ void commitCurrentLogUnit() { if (DEBUG) { - Log.d(TAG, "commitCurrentLogUnit"); + Log.d(TAG, "commitCurrentLogUnit" + (mCurrentLogUnit.hasWord() ? + ": " + mCurrentLogUnit.getWord() : "")); } if (!mCurrentLogUnit.isEmpty()) { if (mMainLogBuffer != null) { mMainLogBuffer.shiftIn(mCurrentLogUnit); - if (mMainLogBuffer.isSafeToLog() && mMainResearchLog != null) { - publishLogBuffer(mMainLogBuffer, mMainResearchLog, - true /* isIncludingPrivateData */); - mMainLogBuffer.resetWordCounter(); - } } if (mFeedbackLogBuffer != null) { mFeedbackLogBuffer.shiftIn(mCurrentLogUnit); } + if (mUserRecordingLogBuffer != null) { + mUserRecordingLogBuffer.shiftIn(mCurrentLogUnit); + } + mCurrentLogUnit = new LogUnit(); + } else { + if (DEBUG) { + Log.d(TAG, "Warning: tried to commit empty log unit."); + } + } + } + + private static final LogStatement LOGSTATEMENT_UNCOMMIT_CURRENT_LOGUNIT = + new LogStatement("UncommitCurrentLogUnit", false, false); + public void uncommitCurrentLogUnit(final String expectedWord, + final boolean dumpCurrentLogUnit) { + // The user has deleted this word and returned to the previous. Check that the word in the + // logUnit matches the expected word. If so, restore the last log unit committed to be the + // current logUnit. I.e., pull out the last LogUnit from all the LogBuffers, and make + // restore it to mCurrentLogUnit so the new edits are captured with the word. Optionally + // dump the contents of mCurrentLogUnit (useful if they contain deletions of the next word + // that should not be reported to protect user privacy) + // + // Note that we don't use mLastLogUnit here, because it only goes one word back and is only + // needed for reverts, which only happen one back. + if (mMainLogBuffer == null) { + return; + } + final LogUnit oldLogUnit = mMainLogBuffer.peekLastLogUnit(); + + // Check that expected word matches. + if (oldLogUnit != null) { + final String oldLogUnitWord = oldLogUnit.getWord(); + if (!oldLogUnitWord.equals(expectedWord)) { + return; + } + } + + // Uncommit, merging if necessary. + mMainLogBuffer.unshiftIn(); + if (oldLogUnit != null && !dumpCurrentLogUnit) { + oldLogUnit.append(mCurrentLogUnit); + mSavedDownEventTime = Long.MAX_VALUE; + } + if (oldLogUnit == null) { mCurrentLogUnit = new LogUnit(); - Log.d(TAG, "commitCurrentLogUnit"); + } else { + mCurrentLogUnit = oldLogUnit; + } + if (mFeedbackLogBuffer != null) { + mFeedbackLogBuffer.unshiftIn(); + } + enqueueEvent(LOGSTATEMENT_UNCOMMIT_CURRENT_LOGUNIT); + if (DEBUG) { + Log.d(TAG, "uncommitCurrentLogUnit (dump=" + dumpCurrentLogUnit + ") back to " + + (mCurrentLogUnit.hasWord() ? ": '" + mCurrentLogUnit.getWord() + "'" : "")); } } + /** + * Publish all the logUnits in the logBuffer, without doing any privacy filtering. + */ /* package for test */ void publishLogBuffer(final LogBuffer logBuffer, - final ResearchLog researchLog, final boolean isIncludingPrivateData) { - LogUnit logUnit; - while ((logUnit = logBuffer.shiftOut()) != null) { - researchLog.publish(logUnit, isIncludingPrivateData); + final ResearchLog researchLog, final boolean canIncludePrivateData) { + publishLogUnits(logBuffer.getLogUnits(), researchLog, canIncludePrivateData); + } + + private static final LogStatement LOGSTATEMENT_LOG_SEGMENT_OPENING = + new LogStatement("logSegmentStart", false, false, "isIncludingPrivateData"); + private static final LogStatement LOGSTATEMENT_LOG_SEGMENT_CLOSING = + new LogStatement("logSegmentEnd", false, false); + /** + * Publish all LogUnits in a list. + * + * Any privacy checks should be performed before calling this method. + */ + /* package for test */ void publishLogUnits(final List<LogUnit> logUnits, + final ResearchLog researchLog, final boolean canIncludePrivateData) { + final LogUnit openingLogUnit = new LogUnit(); + if (logUnits.isEmpty()) return; + // LogUnits not containing private data, such as contextual data for the log, do not require + // logSegment boundary statements. + if (canIncludePrivateData) { + openingLogUnit.addLogStatement(LOGSTATEMENT_LOG_SEGMENT_OPENING, + SystemClock.uptimeMillis(), canIncludePrivateData); + researchLog.publish(openingLogUnit, true /* isIncludingPrivateData */); + } + for (LogUnit logUnit : logUnits) { + if (DEBUG) { + Log.d(TAG, "publishLogBuffer: " + (logUnit.hasWord() ? logUnit.getWord() + : "<wordless>") + ", correction?: " + logUnit.containsCorrection()); + } + researchLog.publish(logUnit, canIncludePrivateData); + } + if (canIncludePrivateData) { + final LogUnit closingLogUnit = new LogUnit(); + closingLogUnit.addLogStatement(LOGSTATEMENT_LOG_SEGMENT_CLOSING, + SystemClock.uptimeMillis()); + researchLog.publish(closingLogUnit, true /* isIncludingPrivateData */); } } - private boolean hasOnlyLetters(final String word) { + public static boolean hasLetters(final String word) { final int length = word.length(); for (int i = 0; i < length; i = word.offsetByCodePoints(i, 1)) { final int codePoint = word.codePointAt(i); - if (!Character.isLetter(codePoint)) { - return false; + if (Character.isLetter(codePoint)) { + return true; } } - return true; + return false; } - private void onWordComplete(final String word) { - Log.d(TAG, "onWordComplete: " + word); - if (word != null && word.length() > 0 && hasOnlyLetters(word)) { + /** + * Commit the portion of mCurrentLogUnit before maxTime as a worded logUnit. + * + * After this operation completes, mCurrentLogUnit will hold any logStatements that happened + * after maxTime. + */ + /* package for test */ void commitCurrentLogUnitAsWord(final String word, final long maxTime, + final boolean isBatchMode) { + if (word == null) { + return; + } + if (word.length() > 0 && hasLetters(word)) { mCurrentLogUnit.setWord(word); - mStatistics.recordWordEntered(); } + final LogUnit newLogUnit = mCurrentLogUnit.splitByTime(maxTime); + enqueueCommitText(word, isBatchMode); commitCurrentLogUnit(); + mCurrentLogUnit = newLogUnit; + } + + /** + * Record the time of a MotionEvent.ACTION_DOWN. + * + * Warning: Not thread safe. Only call from the main thread. + */ + private void setSavedDownEventTime(final long time) { + mSavedDownEventTime = time; + } + + public void onWordFinished(final String word, final boolean isBatchMode) { + commitCurrentLogUnitAsWord(word, mSavedDownEventTime, isBatchMode); + mSavedDownEventTime = Long.MAX_VALUE; } private static int scrubDigitFromCodePoint(int codePoint) { @@ -775,120 +1150,141 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } private String scrubWord(String word) { - if (mDictionary == null) { + final Dictionary dictionary = getDictionary(); + if (dictionary == null) { return WORD_REPLACEMENT_STRING; } - if (mDictionary.isValidWord(word)) { + if (dictionary.isValidWord(word)) { return word; } return WORD_REPLACEMENT_STRING; } - private static final String[] EVENTKEYS_LATINIME_ONSTARTINPUTVIEWINTERNAL = { - "LatinIMEOnStartInputViewInternal", "uuid", "packageName", "inputType", "imeOptions", - "fieldId", "display", "model", "prefs", "versionCode", "versionName", "outputFormatVersion" - }; + // Specific logging methods follow below. The comments for each logging method should + // indicate what specific method is logged, and how to trigger it from the user interface. + // + // Logging methods can be generally classified into two flavors, "UserAction", which should + // correspond closely to an event that is sensed by the IME, and is usually generated + // directly by the user, and "SystemResponse" which corresponds to an event that the IME + // generates, often after much processing of user input. SystemResponses should correspond + // closely to user-visible events. + // TODO: Consider exposing the UserAction classification in the log output. + + /** + * Log a call to LatinIME.onStartInputViewInternal(). + * + * UserAction: called each time the keyboard is opened up. + */ + private static final LogStatement LOGSTATEMENT_LATIN_IME_ON_START_INPUT_VIEW_INTERNAL = + new LogStatement("LatinImeOnStartInputViewInternal", false, false, "uuid", + "packageName", "inputType", "imeOptions", "fieldId", "display", "model", + "prefs", "versionCode", "versionName", "outputFormatVersion", "logEverything", + "isExperimentalDebug"); public static void latinIME_onStartInputViewInternal(final EditorInfo editorInfo, final SharedPreferences prefs) { final ResearchLogger researchLogger = getInstance(); - researchLogger.start(); if (editorInfo != null) { - final Context context = researchLogger.mInputMethodService; + final boolean isPassword = InputTypeUtils.isPasswordInputType(editorInfo.inputType) + || InputTypeUtils.isVisiblePasswordInputType(editorInfo.inputType); + getInstance().setIsPasswordView(isPassword); + researchLogger.start(); + final Context context = researchLogger.mLatinIME; try { final PackageInfo packageInfo; packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); final Integer versionCode = packageInfo.versionCode; final String versionName = packageInfo.versionName; - final Object[] values = { + researchLogger.enqueueEvent(LOGSTATEMENT_LATIN_IME_ON_START_INPUT_VIEW_INTERNAL, researchLogger.mUUIDString, editorInfo.packageName, Integer.toHexString(editorInfo.inputType), Integer.toHexString(editorInfo.imeOptions), editorInfo.fieldId, Build.DISPLAY, Build.MODEL, prefs, versionCode, versionName, - OUTPUT_FORMAT_VERSION - }; - researchLogger.enqueueEvent(EVENTKEYS_LATINIME_ONSTARTINPUTVIEWINTERNAL, values); + OUTPUT_FORMAT_VERSION, IS_LOGGING_EVERYTHING, + ProductionFlag.IS_EXPERIMENTAL_DEBUG); } catch (NameNotFoundException e) { e.printStackTrace(); } } } - public void latinIME_onFinishInputInternal() { + public void latinIME_onFinishInputViewInternal() { stop(); } - private static final String[] EVENTKEYS_USER_FEEDBACK = { - "UserFeedback", "FeedbackContents" - }; - - private static final String[] EVENTKEYS_PREFS_CHANGED = { - "PrefsChanged", "prefs" - }; + /** + * Log a change in preferences. + * + * UserAction: called when the user changes the settings. + */ + private static final LogStatement LOGSTATEMENT_PREFS_CHANGED = + new LogStatement("PrefsChanged", false, false, "prefs"); public static void prefsChanged(final SharedPreferences prefs) { final ResearchLogger researchLogger = getInstance(); - final Object[] values = { - prefs - }; - researchLogger.enqueueEvent(EVENTKEYS_PREFS_CHANGED, values); + researchLogger.enqueueEvent(LOGSTATEMENT_PREFS_CHANGED, prefs); } - // Regular logging methods - - private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_PROCESSMOTIONEVENT = { - "MainKeyboardViewProcessMotionEvent", "action", "eventTime", "id", "x", "y", "size", - "pressure" - }; + /** + * Log a call to MainKeyboardView.processMotionEvent(). + * + * UserAction: called when the user puts their finger onto the screen (ACTION_DOWN). + * + */ + private static final LogStatement LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT = + new LogStatement("MotionEvent", true, false, "action", + LogStatement.KEY_IS_LOGGING_RELATED, "motionEvent"); 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) { - final String actionString; - switch (action) { - case MotionEvent.ACTION_CANCEL: actionString = "CANCEL"; break; - case MotionEvent.ACTION_UP: actionString = "UP"; break; - case MotionEvent.ACTION_DOWN: actionString = "DOWN"; break; - case MotionEvent.ACTION_POINTER_UP: actionString = "POINTER_UP"; break; - case MotionEvent.ACTION_POINTER_DOWN: actionString = "POINTER_DOWN"; break; - case MotionEvent.ACTION_MOVE: actionString = "MOVE"; break; - case MotionEvent.ACTION_OUTSIDE: actionString = "OUTSIDE"; break; - default: actionString = "ACTION_" + action; break; + final String actionString = LoggingUtils.getMotionEventActionTypeString(action); + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT, + actionString, false /* IS_LOGGING_RELATED */, MotionEvent.obtain(me)); + 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). + researchLogger.setSavedDownEventTime(eventTime - 1); + } + // Refresh the timer in case we are capturing user feedback. + if (researchLogger.isMakingUserRecording()) { + researchLogger.resetRecordingTimer(); } - final float size = me.getSize(index); - final float pressure = me.getPressure(index); - final Object[] values = { - actionString, eventTime, id, x, y, size, pressure - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_MAINKEYBOARDVIEW_PROCESSMOTIONEVENT, values); } } - private static final String[] EVENTKEYS_LATINIME_ONCODEINPUT = { - "LatinIMEOnCodeInput", "code", "x", "y" - }; + /** + * Log a call to LatinIME.onCodeInput(). + * + * SystemResponse: The main processing step for entering text. Called when the user performs a + * tap, a flick, a long press, releases a gesture, or taps a punctuation suggestion. + */ + private static final LogStatement LOGSTATEMENT_LATIN_IME_ON_CODE_INPUT = + new LogStatement("LatinImeOnCodeInput", true, false, "code", "x", "y"); public static void latinIME_onCodeInput(final int code, final int x, final int y) { final long time = SystemClock.uptimeMillis(); final ResearchLogger researchLogger = getInstance(); - final Object[] values = { - Keyboard.printableCode(scrubDigitFromCodePoint(code)), x, y - }; - researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONCODEINPUT, values); + researchLogger.enqueueEvent(LOGSTATEMENT_LATIN_IME_ON_CODE_INPUT, + Constants.printableCode(scrubDigitFromCodePoint(code)), x, y); if (Character.isDigit(code)) { researchLogger.setCurrentLogUnitContainsDigitFlag(); } researchLogger.mStatistics.recordChar(code, time); } - - private static final String[] EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS = { - "LatinIMEOnDisplayCompletions", "applicationSpecifiedCompletions" - }; + /** + * Log a call to LatinIME.onDisplayCompletions(). + * + * SystemResponse: The IME has displayed application-specific completions. They may show up + * in the suggestion strip, such as a landscape phone. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_ONDISPLAYCOMPLETIONS = + new LogStatement("LatinIMEOnDisplayCompletions", true, true, + "applicationSpecifiedCompletions"); public static void latinIME_onDisplayCompletions( final CompletionInfo[] applicationSpecifiedCompletions) { - final Object[] values = { - applicationSpecifiedCompletions - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS, - values); + // Note; passing an array as a single element in a vararg list. Must create a new + // dummy array around it or it will get expanded. + getInstance().enqueueEvent(LOGSTATEMENT_LATINIME_ONDISPLAYCOMPLETIONS, + new Object[] { applicationSpecifiedCompletions }); } public static boolean getAndClearLatinIMEExpectingUpdateSelection() { @@ -897,27 +1293,35 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang return returnValue; } - private static final String[] EVENTKEYS_LATINIME_ONWINDOWHIDDEN = { - "LatinIMEOnWindowHidden", "isTextTruncated", "text" - }; + /** + * Log a call to LatinIME.onWindowHidden(). + * + * UserAction: The user has performed an action that has caused the IME to be closed. They may + * have focused on something other than a text field, or explicitly closed it. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_ONWINDOWHIDDEN = + new LogStatement("LatinIMEOnWindowHidden", false, false, "isTextTruncated", "text"); public static void latinIME_onWindowHidden(final int savedSelectionStart, final int savedSelectionEnd, final InputConnection ic) { if (ic != null) { - // Capture the TextView contents. This will trigger onUpdateSelection(), so we - // set sLatinIMEExpectingUpdateSelection so that when onUpdateSelection() is called, - // it can tell that it was generated by the logging code, and not by the user, and - // therefore keep user-visible state as is. - ic.beginBatchEdit(); - ic.performContextMenuAction(android.R.id.selectAll); - CharSequence charSequence = ic.getSelectedText(0); - ic.setSelection(savedSelectionStart, savedSelectionEnd); - ic.endBatchEdit(); - sLatinIMEExpectingUpdateSelection = true; - final Object[] values = new Object[2]; - if (OUTPUT_ENTIRE_BUFFER) { + final boolean isTextTruncated; + final String text; + if (LOG_FULL_TEXTVIEW_CONTENTS) { + // Capture the TextView contents. This will trigger onUpdateSelection(), so we + // set sLatinIMEExpectingUpdateSelection so that when onUpdateSelection() is called, + // it can tell that it was generated by the logging code, and not by the user, and + // therefore keep user-visible state as is. + ic.beginBatchEdit(); + ic.performContextMenuAction(android.R.id.selectAll); + CharSequence charSequence = ic.getSelectedText(0); + if (savedSelectionStart != -1 && savedSelectionEnd != -1) { + ic.setSelection(savedSelectionStart, savedSelectionEnd); + } + ic.endBatchEdit(); + sLatinIMEExpectingUpdateSelection = true; if (TextUtils.isEmpty(charSequence)) { - values[0] = false; - values[1] = ""; + isTextTruncated = false; + text = ""; } else { if (charSequence.length() > MAX_INPUTVIEW_LENGTH_TO_CAPTURE) { int length = MAX_INPUTVIEW_LENGTH_TO_CAPTURE; @@ -928,29 +1332,39 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } final CharSequence truncatedCharSequence = charSequence.subSequence(0, length); - values[0] = true; - values[1] = truncatedCharSequence.toString(); + isTextTruncated = true; + text = truncatedCharSequence.toString(); } else { - values[0] = false; - values[1] = charSequence.toString(); + isTextTruncated = false; + text = charSequence.toString(); } } } else { - values[0] = true; - values[1] = ""; + isTextTruncated = true; + text = ""; } final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueueEvent(EVENTKEYS_LATINIME_ONWINDOWHIDDEN, values); + // Assume that OUTPUT_ENTIRE_BUFFER is only true when we don't care about privacy (e.g. + // during a live user test), so the normal isPotentiallyPrivate and + // isPotentiallyRevealing flags do not apply + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONWINDOWHIDDEN, isTextTruncated, + text); researchLogger.commitCurrentLogUnit(); getInstance().stop(); } } - private static final String[] EVENTKEYS_LATINIME_ONUPDATESELECTION = { - "LatinIMEOnUpdateSelection", "lastSelectionStart", "lastSelectionEnd", "oldSelStart", - "oldSelEnd", "newSelStart", "newSelEnd", "composingSpanStart", "composingSpanEnd", - "expectingUpdateSelection", "expectingUpdateSelectionFromLogger", "context" - }; + /** + * Log a call to LatinIME.onUpdateSelection(). + * + * UserAction/SystemResponse: The user has moved the cursor or selection. This function may + * be called, however, when the system has moved the cursor, say by inserting a character. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_ONUPDATESELECTION = + new LogStatement("LatinIMEOnUpdateSelection", true, false, "lastSelectionStart", + "lastSelectionEnd", "oldSelStart", "oldSelEnd", "newSelStart", "newSelEnd", + "composingSpanStart", "composingSpanEnd", "expectingUpdateSelection", + "expectingUpdateSelectionFromLogger", "context"); public static void latinIME_onUpdateSelection(final int lastSelectionStart, final int lastSelectionEnd, final int oldSelStart, final int oldSelEnd, final int newSelStart, final int newSelEnd, final int composingSpanStart, @@ -966,350 +1380,649 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } final ResearchLogger researchLogger = getInstance(); final String scrubbedWord = researchLogger.scrubWord(word); - final Object[] values = { - lastSelectionStart, lastSelectionEnd, oldSelStart, oldSelEnd, newSelStart, - newSelEnd, composingSpanStart, composingSpanEnd, expectingUpdateSelection, - expectingUpdateSelectionFromLogger, scrubbedWord - }; - researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONUPDATESELECTION, values); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONUPDATESELECTION, lastSelectionStart, + lastSelectionEnd, oldSelStart, oldSelEnd, newSelStart, newSelEnd, + composingSpanStart, composingSpanEnd, expectingUpdateSelection, + expectingUpdateSelectionFromLogger, scrubbedWord); } - private static final String[] EVENTKEYS_LATINIME_PICKSUGGESTIONMANUALLY = { - "LatinIMEPickSuggestionManually", "replacedWord", "index", "suggestion", "x", "y" - }; + /** + * Log a call to LatinIME.onTextInput(). + * + * SystemResponse: Raw text is added to the TextView. + */ + public static void latinIME_onTextInput(final String text, final boolean isBatchMode) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.commitCurrentLogUnitAsWord(text, Long.MAX_VALUE, isBatchMode); + } + + /** + * Log a call to LatinIME.pickSuggestionManually(). + * + * UserAction: The user has chosen a specific word from the suggestion strip. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_PICKSUGGESTIONMANUALLY = + new LogStatement("LatinIMEPickSuggestionManually", true, false, "replacedWord", "index", + "suggestion", "x", "y", "isBatchMode"); public static void latinIME_pickSuggestionManually(final String replacedWord, - final int index, CharSequence suggestion) { - final Object[] values = { - scrubDigitsFromString(replacedWord), index, - (suggestion == null ? null : scrubDigitsFromString(suggestion.toString())), - Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE - }; + final int index, final String suggestion, final boolean isBatchMode) { final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_PICKSUGGESTIONMANUALLY, - values); + if (!replacedWord.equals(suggestion.toString())) { + // The user chose something other than what was already there. + researchLogger.setCurrentLogUnitContainsCorrection(); + researchLogger.setCurrentLogUnitCorrectionType(LogUnit.CORRECTIONTYPE_TYPO); + } + final String scrubbedWord = scrubDigitsFromString(suggestion); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_PICKSUGGESTIONMANUALLY, + scrubDigitsFromString(replacedWord), index, + suggestion == null ? null : scrubbedWord, Constants.SUGGESTION_STRIP_COORDINATE, + Constants.SUGGESTION_STRIP_COORDINATE, isBatchMode); + researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, Long.MAX_VALUE, isBatchMode); + researchLogger.mStatistics.recordManualSuggestion(SystemClock.uptimeMillis()); } - private static final String[] EVENTKEYS_LATINIME_PUNCTUATIONSUGGESTION = { - "LatinIMEPunctuationSuggestion", "index", "suggestion", "x", "y" - }; - public static void latinIME_punctuationSuggestion(final int index, - final CharSequence suggestion) { - final Object[] values = { - index, suggestion, - Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE - }; - getInstance().enqueueEvent(EVENTKEYS_LATINIME_PUNCTUATIONSUGGESTION, values); + /** + * Log a call to LatinIME.punctuationSuggestion(). + * + * UserAction: The user has chosen punctuation from the punctuation suggestion strip. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_PUNCTUATIONSUGGESTION = + new LogStatement("LatinIMEPunctuationSuggestion", false, false, "index", "suggestion", + "x", "y", "isPrediction"); + public static void latinIME_punctuationSuggestion(final int index, final String suggestion, + final boolean isBatchMode, final boolean isPrediction) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_PUNCTUATIONSUGGESTION, index, suggestion, + Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, + isPrediction); + researchLogger.commitCurrentLogUnitAsWord(suggestion, Long.MAX_VALUE, isBatchMode); } - private static final String[] EVENTKEYS_LATINIME_SENDKEYCODEPOINT = { - "LatinIMESendKeyCodePoint", "code" - }; + /** + * Log a call to LatinIME.sendKeyCodePoint(). + * + * SystemResponse: The IME is inserting text into the TextView for numbers, fixed strings, or + * some other unusual mechanism. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_SENDKEYCODEPOINT = + new LogStatement("LatinIMESendKeyCodePoint", true, false, "code"); public static void latinIME_sendKeyCodePoint(final int code) { - final Object[] values = { - Keyboard.printableCode(scrubDigitFromCodePoint(code)) - }; final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_SENDKEYCODEPOINT, values); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_SENDKEYCODEPOINT, + Constants.printableCode(scrubDigitFromCodePoint(code))); if (Character.isDigit(code)) { researchLogger.setCurrentLogUnitContainsDigitFlag(); } } - private static final String[] EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACE = { - "LatinIMESwapSwapperAndSpace" - }; - public static void latinIME_swapSwapperAndSpace() { - getInstance().enqueueEvent(EVENTKEYS_LATINIME_SWAPSWAPPERANDSPACE, EVENTKEYS_NULLVALUES); + /** + * Log a call to LatinIME.promotePhantomSpace(). + * + * SystemResponse: The IME is inserting a real space in place of a phantom space. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_PROMOTEPHANTOMSPACE = + new LogStatement("LatinIMEPromotPhantomSpace", false, false); + public static void latinIME_promotePhantomSpace() { + final ResearchLogger researchLogger = getInstance(); + final LogUnit logUnit; + if (researchLogger.mMainLogBuffer == null) { + logUnit = researchLogger.mCurrentLogUnit; + } else { + logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit(); + } + researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_PROMOTEPHANTOMSPACE); } - private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_ONLONGPRESS = { - "MainKeyboardViewOnLongPress" - }; + /** + * Log a call to LatinIME.swapSwapperAndSpace(). + * + * SystemResponse: A symbol has been swapped with a space character. E.g. punctuation may swap + * if a soft space is inserted after a word. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_SWAPSWAPPERANDSPACE = + new LogStatement("LatinIMESwapSwapperAndSpace", false, false, "originalCharacters", + "charactersAfterSwap"); + public static void latinIME_swapSwapperAndSpace(final CharSequence originalCharacters, + final String charactersAfterSwap) { + final ResearchLogger researchLogger = getInstance(); + final LogUnit logUnit; + if (researchLogger.mMainLogBuffer == null) { + logUnit = null; + } else { + logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit(); + } + if (logUnit != null) { + researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_SWAPSWAPPERANDSPACE, + originalCharacters, charactersAfterSwap); + } + } + + /** + * Log a call to LatinIME.maybeDoubleSpacePeriod(). + * + * SystemResponse: Two spaces have been replaced by period space. + */ + public static void latinIME_maybeDoubleSpacePeriod(final String text, + final boolean isBatchMode) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.commitCurrentLogUnitAsWord(text, Long.MAX_VALUE, isBatchMode); + } + + /** + * Log a call to MainKeyboardView.onLongPress(). + * + * UserAction: The user has performed a long-press on a key. + */ + private static final LogStatement LOGSTATEMENT_MAINKEYBOARDVIEW_ONLONGPRESS = + new LogStatement("MainKeyboardViewOnLongPress", false, false); public static void mainKeyboardView_onLongPress() { - getInstance().enqueueEvent(EVENTKEYS_MAINKEYBOARDVIEW_ONLONGPRESS, EVENTKEYS_NULLVALUES); + getInstance().enqueueEvent(LOGSTATEMENT_MAINKEYBOARDVIEW_ONLONGPRESS); } - private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_SETKEYBOARD = { - "MainKeyboardViewSetKeyboard", "elementId", "locale", "orientation", "width", - "modeName", "action", "navigateNext", "navigatePrevious", "clobberSettingsKey", - "passwordInput", "shortcutKeyEnabled", "hasShortcutKey", "languageSwitchKeyEnabled", - "isMultiLine", "tw", "th", "keys" - }; + /** + * Log a call to MainKeyboardView.setKeyboard(). + * + * SystemResponse: The IME has switched to a new keyboard (e.g. French, English). + * This is typically called right after LatinIME.onStartInputViewInternal (when starting a new + * IME), but may happen at other times if the user explicitly requests a keyboard change. + */ + private static final LogStatement LOGSTATEMENT_MAINKEYBOARDVIEW_SETKEYBOARD = + new LogStatement("MainKeyboardViewSetKeyboard", false, false, "elementId", "locale", + "orientation", "width", "modeName", "action", "navigateNext", + "navigatePrevious", "clobberSettingsKey", "passwordInput", "shortcutKeyEnabled", + "hasShortcutKey", "languageSwitchKeyEnabled", "isMultiLine", "tw", "th", + "keys"); public static void mainKeyboardView_setKeyboard(final Keyboard keyboard) { - if (keyboard != null) { - final KeyboardId kid = keyboard.mId; - final boolean isPasswordView = kid.passwordInput(); - getInstance().setIsPasswordView(isPasswordView); - final Object[] values = { + final KeyboardId kid = keyboard.mId; + final boolean isPasswordView = kid.passwordInput(); + final ResearchLogger researchLogger = getInstance(); + researchLogger.setIsPasswordView(isPasswordView); + researchLogger.enqueueEvent(LOGSTATEMENT_MAINKEYBOARDVIEW_SETKEYBOARD, KeyboardId.elementIdToName(kid.mElementId), kid.mLocale + ":" + kid.mSubtype.getExtraValueOf(KEYBOARD_LAYOUT_SET), - kid.mOrientation, - kid.mWidth, - KeyboardId.modeName(kid.mMode), - kid.imeAction(), - kid.navigateNext(), - kid.navigatePrevious(), - kid.mClobberSettingsKey, - isPasswordView, - kid.mShortcutKeyEnabled, - kid.mHasShortcutKey, - kid.mLanguageSwitchKeyEnabled, - kid.isMultiLine(), - keyboard.mOccupiedWidth, - keyboard.mOccupiedHeight, - keyboard.mKeys - }; - getInstance().setIsPasswordView(isPasswordView); - getInstance().enqueueEvent(EVENTKEYS_MAINKEYBOARDVIEW_SETKEYBOARD, values); - } + kid.mOrientation, kid.mWidth, KeyboardId.modeName(kid.mMode), kid.imeAction(), + kid.navigateNext(), kid.navigatePrevious(), kid.mClobberSettingsKey, + isPasswordView, kid.mShortcutKeyEnabled, kid.mHasShortcutKey, + kid.mLanguageSwitchKeyEnabled, kid.isMultiLine(), keyboard.mOccupiedWidth, + keyboard.mOccupiedHeight, keyboard.mKeys); } - private static final String[] EVENTKEYS_LATINIME_REVERTCOMMIT = { - "LatinIMERevertCommit", "originallyTypedWord" - }; - public static void latinIME_revertCommit(final String originallyTypedWord) { - final Object[] values = { - originallyTypedWord - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_REVERTCOMMIT, values); + /** + * Log a call to LatinIME.revertCommit(). + * + * SystemResponse: The IME has reverted commited text. This happens when the user enters + * a word, commits it by pressing space or punctuation, and then reverts the commit by hitting + * backspace. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_REVERTCOMMIT = + new LogStatement("LatinIMERevertCommit", true, false, "committedWord", + "originallyTypedWord", "separatorString"); + public static void latinIME_revertCommit(final String committedWord, + final String originallyTypedWord, final boolean isBatchMode, + final String separatorString) { + final ResearchLogger researchLogger = getInstance(); + // TODO: Verify that mCurrentLogUnit has been restored and contains the reverted word. + final LogUnit logUnit; + if (researchLogger.mMainLogBuffer == null) { + logUnit = null; + } else { + logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit(); + } + if (originallyTypedWord.length() > 0 && hasLetters(originallyTypedWord)) { + if (logUnit != null) { + logUnit.setWord(originallyTypedWord); + } + } + researchLogger.enqueueEvent(logUnit != null ? logUnit : researchLogger.mCurrentLogUnit, + LOGSTATEMENT_LATINIME_REVERTCOMMIT, committedWord, originallyTypedWord, + separatorString); + if (logUnit != null) { + logUnit.setContainsCorrection(); + } + researchLogger.mStatistics.recordRevertCommit(SystemClock.uptimeMillis()); + researchLogger.commitCurrentLogUnitAsWord(originallyTypedWord, Long.MAX_VALUE, isBatchMode); } - private static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONCANCELINPUT = { - "PointerTrackerCallListenerOnCancelInput" - }; + /** + * Log a call to PointerTracker.callListenerOnCancelInput(). + * + * UserAction: The user has canceled the input, e.g., by pressing down, but then removing + * outside the keyboard area. + * TODO: Verify + */ + private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCANCELINPUT = + new LogStatement("PointerTrackerCallListenerOnCancelInput", false, false); public static void pointerTracker_callListenerOnCancelInput() { - getInstance().enqueueEvent(EVENTKEYS_POINTERTRACKER_CALLLISTENERONCANCELINPUT, - EVENTKEYS_NULLVALUES); + getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCANCELINPUT); } - private static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONCODEINPUT = { - "PointerTrackerCallListenerOnCodeInput", "code", "outputText", "x", "y", - "ignoreModifierKey", "altersCode", "isEnabled" - }; + /** + * Log a call to PointerTracker.callListenerOnCodeInput(). + * + * SystemResponse: The user has entered a key through the normal tapping mechanism. + * LatinIME.onCodeInput will also be called. + */ + private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT = + new LogStatement("PointerTrackerCallListenerOnCodeInput", true, false, "code", + "outputText", "x", "y", "ignoreModifierKey", "altersCode", "isEnabled"); public static void pointerTracker_callListenerOnCodeInput(final Key key, final int x, final int y, final boolean ignoreModifierKey, final boolean altersCode, final int code) { if (key != null) { String outputText = key.getOutputText(); - final Object[] values = { - Keyboard.printableCode(scrubDigitFromCodePoint(code)), outputText == null ? null - : scrubDigitsFromString(outputText.toString()), - x, y, ignoreModifierKey, altersCode, key.isEnabled() - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_POINTERTRACKER_CALLLISTENERONCODEINPUT, values); + 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 static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONRELEASE = { - "PointerTrackerCallListenerOnRelease", "code", "withSliding", "ignoreModifierKey", - "isEnabled" - }; + private void suppressResearchKeyMotionData() { + mCurrentLogUnit.removeResearchButtonInvocation(); + } + + /** + * Log a call to PointerTracker.callListenerCallListenerOnRelease(). + * + * UserAction: The user has released their finger or thumb from the screen. + */ + private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONRELEASE = + new LogStatement("PointerTrackerCallListenerOnRelease", true, false, "code", + "withSliding", "ignoreModifierKey", "isEnabled"); public static void pointerTracker_callListenerOnRelease(final Key key, final int primaryCode, final boolean withSliding, final boolean ignoreModifierKey) { if (key != null) { - final Object[] values = { - Keyboard.printableCode(scrubDigitFromCodePoint(primaryCode)), withSliding, - ignoreModifierKey, key.isEnabled() - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_POINTERTRACKER_CALLLISTENERONRELEASE, values); + getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONRELEASE, + Constants.printableCode(scrubDigitFromCodePoint(primaryCode)), withSliding, + ignoreModifierKey, key.isEnabled()); } } - private static final String[] EVENTKEYS_POINTERTRACKER_ONDOWNEVENT = { - "PointerTrackerOnDownEvent", "deltaT", "distanceSquared" - }; + /** + * Log a call to PointerTracker.onDownEvent(). + * + * UserAction: The user has pressed down on a key. + * TODO: Differentiate with LatinIME.processMotionEvent. + */ + private static final LogStatement LOGSTATEMENT_POINTERTRACKER_ONDOWNEVENT = + new LogStatement("PointerTrackerOnDownEvent", true, false, "deltaT", "distanceSquared"); public static void pointerTracker_onDownEvent(long deltaT, int distanceSquared) { - final Object[] values = { - deltaT, distanceSquared - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_POINTERTRACKER_ONDOWNEVENT, values); + getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_ONDOWNEVENT, deltaT, + distanceSquared); } - private static final String[] EVENTKEYS_POINTERTRACKER_ONMOVEEVENT = { - "PointerTrackerOnMoveEvent", "x", "y", "lastX", "lastY" - }; + /** + * Log a call to PointerTracker.onMoveEvent(). + * + * UserAction: The user has moved their finger while pressing on the screen. + * TODO: Differentiate with LatinIME.processMotionEvent(). + */ + private static final LogStatement LOGSTATEMENT_POINTERTRACKER_ONMOVEEVENT = + new LogStatement("PointerTrackerOnMoveEvent", true, false, "x", "y", "lastX", "lastY"); public static void pointerTracker_onMoveEvent(final int x, final int y, final int lastX, final int lastY) { - final Object[] values = { - x, y, lastX, lastY - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_POINTERTRACKER_ONMOVEEVENT, values); + getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_ONMOVEEVENT, x, y, lastX, lastY); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITCOMPLETION = { - "RichInputConnectionCommitCompletion", "completionInfo" - }; + /** + * Log a call to RichInputConnection.commitCompletion(). + * + * SystemResponse: The IME has committed a completion. A completion is an application- + * specific suggestion that is presented in a pop-up menu in the TextView. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_COMMITCOMPLETION = + new LogStatement("RichInputConnectionCommitCompletion", true, false, "completionInfo"); public static void richInputConnection_commitCompletion(final CompletionInfo completionInfo) { - final Object[] values = { - completionInfo - }; final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueuePotentiallyPrivateEvent( - EVENTKEYS_RICHINPUTCONNECTION_COMMITCOMPLETION, values); - } - - // Disabled for privacy-protection reasons. Because this event comes after - // richInputConnection_commitText, which is the event used to separate LogUnits, the - // data in this event can be associated with the next LogUnit, revealing information - // about the current word even if it was supposed to be suppressed. The occurrance of - // autocorrection can be determined by examining the difference between the text strings in - // the last call to richInputConnection_setComposingText before - // richInputConnection_commitText, so it's not a data loss. - // TODO: Figure out how to log this event without loss of privacy. - /* - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION = { - "RichInputConnectionCommitCorrection", "typedWord", "autoCorrection" - }; - */ - public static void richInputConnection_commitCorrection(CorrectionInfo correctionInfo) { - /* - final String typedWord = correctionInfo.getOldText().toString(); - final String autoCorrection = correctionInfo.getNewText().toString(); - final Object[] values = { - scrubDigitsFromString(typedWord), scrubDigitsFromString(autoCorrection) - }; + researchLogger.enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_COMMITCOMPLETION, + completionInfo); + } + + /** + * Log a call to RichInputConnection.revertDoubleSpacePeriod(). + * + * SystemResponse: The IME has reverted ". ", which had previously replaced two typed spaces. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_REVERTDOUBLESPACEPERIOD = + new LogStatement("RichInputConnectionRevertDoubleSpacePeriod", false, false); + public static void richInputConnection_revertDoubleSpacePeriod() { + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_REVERTDOUBLESPACEPERIOD); + } + + /** + * Log a call to RichInputConnection.revertSwapPunctuation(). + * + * SystemResponse: The IME has reverted a punctuation swap. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_REVERTSWAPPUNCTUATION = + new LogStatement("RichInputConnectionRevertSwapPunctuation", false, false); + public static void richInputConnection_revertSwapPunctuation() { + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_REVERTSWAPPUNCTUATION); + } + + /** + * Log a call to LatinIME.commitCurrentAutoCorrection(). + * + * SystemResponse: The IME has committed an auto-correction. An auto-correction changes the raw + * text input to another word that the user more likely desired to type. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_COMMITCURRENTAUTOCORRECTION = + new LogStatement("LatinIMECommitCurrentAutoCorrection", true, true, "typedWord", + "autoCorrection", "separatorString"); + public static void latinIme_commitCurrentAutoCorrection(final String typedWord, + final String autoCorrection, final String separatorString, final boolean isBatchMode, + final SuggestedWords suggestedWords) { + final String scrubbedTypedWord = scrubDigitsFromString(typedWord); + final String scrubbedAutoCorrection = scrubDigitsFromString(autoCorrection); final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueuePotentiallyPrivateEvent( - EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION, values); - */ + researchLogger.mCurrentLogUnit.initializeSuggestions(suggestedWords); + researchLogger.commitCurrentLogUnitAsWord(scrubbedAutoCorrection, Long.MAX_VALUE, + isBatchMode); + + // Add the autocorrection logStatement at the end of the logUnit for the committed word. + // We have to do this after calling commitCurrentLogUnitAsWord, because it may split the + // current logUnit, and then we have to peek to get the logUnit reference back. + final LogUnit logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit(); + // TODO: Add test to confirm that the commitCurrentAutoCorrection log statement should + // always be added to logUnit (if non-null) and not mCurrentLogUnit. + researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_COMMITCURRENTAUTOCORRECTION, + scrubbedTypedWord, scrubbedAutoCorrection, separatorString); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT = { - "RichInputConnectionCommitText", "typedWord", "newCursorPosition" - }; - public static void richInputConnection_commitText(final CharSequence typedWord, - final int newCursorPosition) { - final String scrubbedWord = scrubDigitsFromString(typedWord.toString()); - final Object[] values = { - scrubbedWord, newCursorPosition - }; + private boolean isExpectingCommitText = false; + /** + * Log a call to (UnknownClass).commitPartialText + * + * SystemResponse: The IME is committing part of a word. This happens if a space is + * automatically inserted to split a single typed string into two or more words. + */ + // TODO: This method is currently unused. Find where it should be called from in the IME and + // add invocations. + private static final LogStatement LOGSTATEMENT_COMMIT_PARTIAL_TEXT = + new LogStatement("CommitPartialText", true, false, "newCursorPosition"); + public static void commitPartialText(final String committedWord, + final long lastTimestampOfWordData, final boolean isBatchMode) { final ResearchLogger researchLogger = getInstance(); - researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT, - values); - researchLogger.onWordComplete(scrubbedWord); + final String scrubbedWord = scrubDigitsFromString(committedWord); + researchLogger.enqueueEvent(LOGSTATEMENT_COMMIT_PARTIAL_TEXT); + researchLogger.mStatistics.recordAutoCorrection(SystemClock.uptimeMillis()); + researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, lastTimestampOfWordData, + isBatchMode); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT = { - "RichInputConnectionDeleteSurroundingText", "beforeLength", "afterLength" - }; + /** + * Log a call to RichInputConnection.commitText(). + * + * SystemResponse: The IME is committing text. This happens after the user has typed a word + * and then a space or punctuation key. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTIONCOMMITTEXT = + new LogStatement("RichInputConnectionCommitText", true, false, "newCursorPosition"); + public static void richInputConnection_commitText(final String committedWord, + final int newCursorPosition, final boolean isBatchMode) { + final ResearchLogger researchLogger = getInstance(); + // Only include opening and closing logSegments if private data is included + final String scrubbedWord = scrubDigitsFromString(committedWord); + if (!researchLogger.isExpectingCommitText) { + researchLogger.enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTIONCOMMITTEXT, + newCursorPosition); + researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, Long.MAX_VALUE, isBatchMode); + } + researchLogger.isExpectingCommitText = false; + } + + /** + * Shared event for logging committed text. + */ + private static final LogStatement LOGSTATEMENT_COMMITTEXT = + new LogStatement("CommitText", true, false, "committedText", "isBatchMode"); + private void enqueueCommitText(final String word, final boolean isBatchMode) { + enqueueEvent(LOGSTATEMENT_COMMITTEXT, word, isBatchMode); + } + + /** + * Log a call to RichInputConnection.deleteSurroundingText(). + * + * SystemResponse: The IME has deleted text. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT = + new LogStatement("RichInputConnectionDeleteSurroundingText", true, false, + "beforeLength", "afterLength"); public static void richInputConnection_deleteSurroundingText(final int beforeLength, final int afterLength) { - final Object[] values = { - beforeLength, afterLength - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, values); + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, + beforeLength, afterLength); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT = { - "RichInputConnectionFinishComposingText" - }; + /** + * Log a call to RichInputConnection.finishComposingText(). + * + * SystemResponse: The IME has left the composing text as-is. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT = + new LogStatement("RichInputConnectionFinishComposingText", false, false); public static void richInputConnection_finishComposingText() { - getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT, - EVENTKEYS_NULLVALUES); + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_PERFORMEDITORACTION = { - "RichInputConnectionPerformEditorAction", "imeActionNext" - }; - public static void richInputConnection_performEditorAction(final int imeActionNext) { - final Object[] values = { - imeActionNext - }; - getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_PERFORMEDITORACTION, values); + /** + * Log a call to RichInputConnection.performEditorAction(). + * + * SystemResponse: The IME is invoking an action specific to the editor. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_PERFORMEDITORACTION = + new LogStatement("RichInputConnectionPerformEditorAction", false, false, + "imeActionId"); + public static void richInputConnection_performEditorAction(final int imeActionId) { + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_PERFORMEDITORACTION, + imeActionId); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT = { - "RichInputConnectionSendKeyEvent", "eventTime", "action", "code" - }; + /** + * Log a call to RichInputConnection.sendKeyEvent(). + * + * SystemResponse: The IME is telling the TextView that a key is being pressed through an + * alternate channel. + * TODO: only for hardware keys? + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SENDKEYEVENT = + new LogStatement("RichInputConnectionSendKeyEvent", true, false, "eventTime", "action", + "code"); public static void richInputConnection_sendKeyEvent(final KeyEvent keyEvent) { - final Object[] values = { - keyEvent.getEventTime(), - keyEvent.getAction(), - keyEvent.getKeyCode() - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT, - values); + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SENDKEYEVENT, + keyEvent.getEventTime(), keyEvent.getAction(), keyEvent.getKeyCode()); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT = { - "RichInputConnectionSetComposingText", "text", "newCursorPosition" - }; + /** + * Log a call to RichInputConnection.setComposingText(). + * + * SystemResponse: The IME is setting the composing text. Happens each time a character is + * entered. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SETCOMPOSINGTEXT = + new LogStatement("RichInputConnectionSetComposingText", true, true, "text", + "newCursorPosition"); public static void richInputConnection_setComposingText(final CharSequence text, final int newCursorPosition) { if (text == null) { throw new RuntimeException("setComposingText is null"); } - final Object[] values = { - text, newCursorPosition - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT, - values); + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SETCOMPOSINGTEXT, text, + newCursorPosition); } - private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION = { - "RichInputConnectionSetSelection", "from", "to" - }; + /** + * Log a call to RichInputConnection.setSelection(). + * + * SystemResponse: The IME is requesting that the selection change. User-initiated selection- + * change requests do not go through this method -- it's only when the system wants to change + * the selection. + */ + private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SETSELECTION = + new LogStatement("RichInputConnectionSetSelection", true, false, "from", "to"); public static void richInputConnection_setSelection(final int from, final int to) { - final Object[] values = { - from, to - }; - getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION, - values); + getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SETSELECTION, from, to); } - private static final String[] EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT = { - "SuddenJumpingTouchEventHandlerOnTouchEvent", "motionEvent" - }; + /** + * Log a call to SuddenJumpingTouchEventHandler.onTouchEvent(). + * + * SystemResponse: The IME has filtered input events in case of an erroneous sensor reading. + */ + private static final LogStatement LOGSTATEMENT_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT = + new LogStatement("SuddenJumpingTouchEventHandlerOnTouchEvent", true, false, + "motionEvent"); public static void suddenJumpingTouchEventHandler_onTouchEvent(final MotionEvent me) { if (me != null) { - final Object[] values = { - me.toString() - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT, values); + getInstance().enqueueEvent(LOGSTATEMENT_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT, + me.toString()); } } - private static final String[] EVENTKEYS_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS = { - "SuggestionStripViewSetSuggestions", "suggestedWords" - }; + /** + * Log a call to SuggestionsView.setSuggestions(). + * + * SystemResponse: The IME is setting the suggestions in the suggestion strip. + */ + private static final LogStatement LOGSTATEMENT_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS = + new LogStatement("SuggestionStripViewSetSuggestions", true, true, "suggestedWords"); public static void suggestionStripView_setSuggestions(final SuggestedWords suggestedWords) { if (suggestedWords != null) { - final Object[] values = { - suggestedWords - }; - getInstance().enqueuePotentiallyPrivateEvent( - EVENTKEYS_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS, values); + getInstance().enqueueEvent(LOGSTATEMENT_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS, + suggestedWords); } } - private static final String[] EVENTKEYS_USER_TIMESTAMP = { - "UserTimestamp" - }; + /** + * The user has indicated a particular point in the log that is of interest. + * + * UserAction: From direct menu invocation. + */ + private static final LogStatement LOGSTATEMENT_USER_TIMESTAMP = + new LogStatement("UserTimestamp", false, false); public void userTimestamp() { - getInstance().enqueueEvent(EVENTKEYS_USER_TIMESTAMP, EVENTKEYS_NULLVALUES); + getInstance().enqueueEvent(LOGSTATEMENT_USER_TIMESTAMP); } - private static final String[] EVENTKEYS_STATISTICS = { - "Statistics", "charCount", "letterCount", "numberCount", "spaceCount", "deleteOpsCount", - "wordCount", "isEmptyUponStarting", "isEmptinessStateKnown", "averageTimeBetweenKeys", - "averageTimeBeforeDelete", "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete" - }; + /** + * Log a call to LatinIME.onEndBatchInput(). + * + * SystemResponse: The system has completed a gesture. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_ONENDBATCHINPUT = + new LogStatement("LatinIMEOnEndBatchInput", true, false, "enteredText", + "enteredWordPos"); + public static void latinIME_onEndBatchInput(final CharSequence enteredText, + final int enteredWordPos, final SuggestedWords suggestedWords) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONENDBATCHINPUT, enteredText, + enteredWordPos); + researchLogger.mCurrentLogUnit.initializeSuggestions(suggestedWords); + researchLogger.mStatistics.recordGestureInput(enteredText.length(), + SystemClock.uptimeMillis()); + } + + /** + * Log a call to LatinIME.handleBackspace() that is not a batch delete. + * + * UserInput: The user is deleting one or more characters by hitting the backspace key once. + * The covers single character deletes as well as deleting selections. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_HANDLEBACKSPACE = + new LogStatement("LatinIMEHandleBackspace", true, false, "numCharacters"); + public static void latinIME_handleBackspace(final int numCharacters) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLEBACKSPACE, numCharacters); + } + + /** + * Log a call to LatinIME.handleBackspace() that is a batch delete. + * + * UserInput: The user is deleting a gestured word by hitting the backspace key once. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_HANDLEBACKSPACE_BATCH = + new LogStatement("LatinIMEHandleBackspaceBatch", true, false, "deletedText", + "numCharacters"); + public static void latinIME_handleBackspace_batch(final CharSequence deletedText, + final int numCharacters) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLEBACKSPACE_BATCH, deletedText, + numCharacters); + researchLogger.mStatistics.recordGestureDelete(deletedText.length(), + SystemClock.uptimeMillis()); + } + + /** + * Log a long interval between user operation. + * + * UserInput: The user has not done anything for a while. + */ + private static final LogStatement LOGSTATEMENT_ONUSERPAUSE = new LogStatement("OnUserPause", + false, false, "intervalInMs"); + public static void onUserPause(final long interval) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_ONUSERPAUSE, interval); + } + + /** + * Record the current time in case the LogUnit is later split. + * + * If the current logUnit is split, then tapping, motion events, etc. before this time should + * be assigned to one LogUnit, and events after this time should go into the following LogUnit. + */ + public static void recordTimeForLogUnitSplit() { + final ResearchLogger researchLogger = getInstance(); + researchLogger.setSavedDownEventTime(SystemClock.uptimeMillis()); + researchLogger.mSavedDownEventTime = Long.MAX_VALUE; + } + + /** + * Log a call to LatinIME.handleSeparator() + * + * SystemResponse: The system is inserting a separator character, possibly performing auto- + * correction or other actions appropriate at the end of a word. + */ + private static final LogStatement LOGSTATEMENT_LATINIME_HANDLESEPARATOR = + new LogStatement("LatinIMEHandleSeparator", false, false, "primaryCode", + "isComposingWord"); + public static void latinIME_handleSeparator(final int primaryCode, + final boolean isComposingWord) { + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLESEPARATOR, primaryCode, + isComposingWord); + } + + /** + * Log statistics. + * + * ContextualData, recorded at the end of a session. + */ + private static final LogStatement LOGSTATEMENT_STATISTICS = + new LogStatement("Statistics", false, false, "charCount", "letterCount", "numberCount", + "spaceCount", "deleteOpsCount", "wordCount", "isEmptyUponStarting", + "isEmptinessStateKnown", "averageTimeBetweenKeys", "averageTimeBeforeDelete", + "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete", + "dictionaryWordCount", "splitWordsCount", "gestureInputCount", + "gestureCharsCount", "gesturesDeletedCount", "manualSuggestionsCount", + "revertCommitsCount", "correctedWordsCount", "autoCorrectionsCount"); private static void logStatistics() { final ResearchLogger researchLogger = getInstance(); final Statistics statistics = researchLogger.mStatistics; - final Object[] values = { - statistics.mCharCount, statistics.mLetterCount, statistics.mNumberCount, - statistics.mSpaceCount, statistics.mDeleteKeyCount, - statistics.mWordCount, statistics.mIsEmptyUponStarting, - statistics.mIsEmptinessStateKnown, statistics.mKeyCounter.getAverageTime(), - statistics.mBeforeDeleteKeyCounter.getAverageTime(), - statistics.mDuringRepeatedDeleteKeysCounter.getAverageTime(), - statistics.mAfterDeleteKeyCounter.getAverageTime() - }; - researchLogger.enqueueEvent(EVENTKEYS_STATISTICS, values); + researchLogger.enqueueEvent(LOGSTATEMENT_STATISTICS, statistics.mCharCount, + statistics.mLetterCount, statistics.mNumberCount, statistics.mSpaceCount, + statistics.mDeleteKeyCount, statistics.mWordCount, statistics.mIsEmptyUponStarting, + statistics.mIsEmptinessStateKnown, statistics.mKeyCounter.getAverageTime(), + statistics.mBeforeDeleteKeyCounter.getAverageTime(), + statistics.mDuringRepeatedDeleteKeysCounter.getAverageTime(), + statistics.mAfterDeleteKeyCounter.getAverageTime(), + statistics.mDictionaryWordCount, statistics.mSplitWordsCount, + statistics.mGesturesInputCount, statistics.mGesturesCharsCount, + statistics.mGesturesDeletedCount, statistics.mManualSuggestionsCount, + statistics.mRevertCommitsCount, statistics.mCorrectedWordsCount, + statistics.mAutoCorrectionsCount); } } diff --git a/java/src/com/android/inputmethod/research/Statistics.java b/java/src/com/android/inputmethod/research/Statistics.java index eab465aa2..50e2b7fbc 100644 --- a/java/src/com/android/inputmethod/research/Statistics.java +++ b/java/src/com/android/inputmethod/research/Statistics.java @@ -1,24 +1,31 @@ /* * 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 + * 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 + * 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. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.android.inputmethod.research; -import com.android.inputmethod.keyboard.Keyboard; +import android.util.Log; + +import com.android.inputmethod.latin.Constants; +import com.android.inputmethod.latin.define.ProductionFlag; public class Statistics { + private static final String TAG = Statistics.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + + // TODO: Cleanup comments to only including those giving meaningful information. // Number of characters entered during a typing session int mCharCount; // Number of letter characters entered during a typing session @@ -31,6 +38,24 @@ public class Statistics { int mDeleteKeyCount; // Number of words entered during a session. int mWordCount; + // Number of words found in the dictionary. + int mDictionaryWordCount; + // Number of words split and spaces automatically entered. + int mSplitWordsCount; + // Number of words entered during a session. + int mCorrectedWordsCount; + // Number of gestures that were input. + int mGesturesInputCount; + // Number of gestures that were deleted. + int mGesturesDeletedCount; + // Total number of characters in words entered by gesture. + int mGesturesCharsCount; + // Number of manual suggestions chosen. + int mManualSuggestionsCount; + // Number of times that autocorrection was invoked. + int mAutoCorrectionsCount; + // Number of times a commit was reverted in this session. + int mRevertCommitsCount; // Whether the text field was empty upon editing boolean mIsEmptyUponStarting; boolean mIsEmptinessStateKnown; @@ -91,29 +116,34 @@ public class Statistics { mSpaceCount = 0; mDeleteKeyCount = 0; mWordCount = 0; + mDictionaryWordCount = 0; + mSplitWordsCount = 0; + mCorrectedWordsCount = 0; + mGesturesInputCount = 0; + mGesturesDeletedCount = 0; + mManualSuggestionsCount = 0; + mRevertCommitsCount = 0; + mAutoCorrectionsCount = 0; mIsEmptyUponStarting = true; mIsEmptinessStateKnown = false; mKeyCounter.reset(); mBeforeDeleteKeyCounter.reset(); mDuringRepeatedDeleteKeysCounter.reset(); mAfterDeleteKeyCounter.reset(); + mGesturesCharsCount = 0; + mGesturesDeletedCount = 0; mLastTapTime = 0; mIsLastKeyDeleteKey = false; } public void recordChar(int codePoint, long time) { - final long delta = time - mLastTapTime; - if (codePoint == Keyboard.CODE_DELETE) { + if (DEBUG) { + Log.d(TAG, "recordChar() called"); + } + if (codePoint == Constants.CODE_DELETE) { mDeleteKeyCount++; - if (delta < MIN_DELETION_INTERMISSION) { - if (mIsLastKeyDeleteKey) { - mDuringRepeatedDeleteKeysCounter.add(delta); - } else { - mBeforeDeleteKeyCounter.add(delta); - } - } - mIsLastKeyDeleteKey = true; + recordUserAction(time, true /* isDeletion */); } else { mCharCount++; if (Character.isDigit(codePoint)) { @@ -125,22 +155,78 @@ public class Statistics { if (Character.isSpaceChar(codePoint)) { mSpaceCount++; } - if (mIsLastKeyDeleteKey && delta < MIN_DELETION_INTERMISSION) { - mAfterDeleteKeyCounter.add(delta); - } else if (!mIsLastKeyDeleteKey && delta < MIN_TYPING_INTERMISSION) { - mKeyCounter.add(delta); - } - mIsLastKeyDeleteKey = false; + recordUserAction(time, false /* isDeletion */); } - mLastTapTime = time; } - public void recordWordEntered() { + public void recordWordEntered(final boolean isDictionaryWord, + final boolean containsCorrection) { mWordCount++; + if (isDictionaryWord) { + mDictionaryWordCount++; + } + if (containsCorrection) { + mCorrectedWordsCount++; + } + } + + public void recordSplitWords() { + mSplitWordsCount++; + } + + public void recordGestureInput(final int numCharsEntered, final long time) { + mGesturesInputCount++; + mGesturesCharsCount += numCharsEntered; + recordUserAction(time, false /* isDeletion */); } public void setIsEmptyUponStarting(final boolean isEmpty) { mIsEmptyUponStarting = isEmpty; mIsEmptinessStateKnown = true; } + + public void recordGestureDelete(final int length, final long time) { + mGesturesDeletedCount++; + recordUserAction(time, true /* isDeletion */); + } + + public void recordManualSuggestion(final long time) { + mManualSuggestionsCount++; + recordUserAction(time, false /* isDeletion */); + } + + public void recordAutoCorrection(final long time) { + mAutoCorrectionsCount++; + recordUserAction(time, false /* isDeletion */); + } + + public void recordRevertCommit(final long time) { + mRevertCommitsCount++; + recordUserAction(time, true /* isDeletion */); + } + + private void recordUserAction(final long time, final boolean isDeletion) { + final long delta = time - mLastTapTime; + if (isDeletion) { + if (delta < MIN_DELETION_INTERMISSION) { + if (mIsLastKeyDeleteKey) { + mDuringRepeatedDeleteKeysCounter.add(delta); + } else { + mBeforeDeleteKeyCounter.add(delta); + } + } else { + ResearchLogger.onUserPause(delta); + } + } else { + if (mIsLastKeyDeleteKey && delta < MIN_DELETION_INTERMISSION) { + mAfterDeleteKeyCounter.add(delta); + } else if (!mIsLastKeyDeleteKey && delta < MIN_TYPING_INTERMISSION) { + mKeyCounter.add(delta); + } else { + ResearchLogger.onUserPause(delta); + } + } + mIsLastKeyDeleteKey = isDeletion; + mLastTapTime = time; + } } diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java index 7a5749096..89c67fbb2 100644 --- a/java/src/com/android/inputmethod/research/UploaderService.java +++ b/java/src/com/android/inputmethod/research/UploaderService.java @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * 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; @@ -30,6 +30,7 @@ import android.os.Bundle; import android.util.Log; import com.android.inputmethod.latin.R; +import com.android.inputmethod.latin.define.ProductionFlag; import java.io.BufferedReader; import java.io.File; @@ -45,8 +46,12 @@ import java.net.URL; public final class UploaderService extends IntentService { private static final String TAG = UploaderService.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + // Set IS_INHIBITING_AUTO_UPLOAD to true for local testing + private static final boolean IS_INHIBITING_AUTO_UPLOAD = false + && ProductionFlag.IS_EXPERIMENTAL_DEBUG; // Force false in production public static final long RUN_INTERVAL = AlarmManager.INTERVAL_HOUR; - private static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName() + public static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName() + ".extra.UPLOAD_UNCONDITIONALLY"; private static final int BUF_SIZE = 1024 * 8; protected static final int TIMEOUT_IN_MS = 1000 * 4; @@ -116,7 +121,8 @@ public final class UploaderService extends IntentService { } private void doUpload(final boolean isUploadingUnconditionally) { - if (!isUploadingUnconditionally && (!isExternallyPowered() || !hasWifiConnection())) { + if (!isUploadingUnconditionally && (!isExternallyPowered() || !hasWifiConnection() + || IS_INHIBITING_AUTO_UPLOAD)) { return; } if (mFilesDir == null) { @@ -125,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(); } }); @@ -141,7 +147,9 @@ public final class UploaderService extends IntentService { } private boolean uploadFile(File file) { - Log.d(TAG, "attempting upload of " + file.getAbsolutePath()); + if (DEBUG) { + Log.d(TAG, "attempting upload of " + file.getAbsolutePath()); + } boolean success = false; final int contentLength = (int) file.length(); HttpURLConnection connection = null; @@ -157,6 +165,9 @@ public final class UploaderService extends IntentService { int numBytesRead; while ((numBytesRead = fileInputStream.read(buf)) != -1) { os.write(buf, 0, numBytesRead); + if (DEBUG) { + Log.d(TAG, new String(buf)); + } } if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { Log.d(TAG, "upload failed: " + connection.getResponseCode()); @@ -171,7 +182,9 @@ public final class UploaderService extends IntentService { } file.delete(); success = true; - Log.d(TAG, "upload successful"); + if (DEBUG) { + Log.d(TAG, "upload successful"); + } } catch (Exception e) { e.printStackTrace(); } finally { |