aboutsummaryrefslogtreecommitdiffstats
path: root/java/src/com/android/inputmethod/research
diff options
context:
space:
mode:
Diffstat (limited to 'java/src/com/android/inputmethod/research')
-rw-r--r--java/src/com/android/inputmethod/research/BootBroadcastReceiver.java33
-rw-r--r--java/src/com/android/inputmethod/research/FeedbackActivity.java54
-rw-r--r--java/src/com/android/inputmethod/research/FeedbackFragment.java73
-rw-r--r--java/src/com/android/inputmethod/research/FeedbackLayout.java62
-rw-r--r--java/src/com/android/inputmethod/research/LogBuffer.java113
-rw-r--r--java/src/com/android/inputmethod/research/LogUnit.java83
-rw-r--r--java/src/com/android/inputmethod/research/MainLogBuffer.java127
-rw-r--r--java/src/com/android/inputmethod/research/ResearchLog.java310
-rw-r--r--java/src/com/android/inputmethod/research/ResearchLogger.java1270
-rw-r--r--java/src/com/android/inputmethod/research/Statistics.java146
-rw-r--r--java/src/com/android/inputmethod/research/UploaderService.java191
11 files changed, 2462 insertions, 0 deletions
diff --git a/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java
new file mode 100644
index 000000000..5124a35a6
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/BootBroadcastReceiver.java
@@ -0,0 +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
+ *
+ * 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.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Arrange for the uploading service to be run on regular intervals.
+ */
+public final class BootBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+ ResearchLogger.scheduleUploadingService(context);
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/FeedbackActivity.java b/java/src/com/android/inputmethod/research/FeedbackActivity.java
new file mode 100644
index 000000000..11eae8813
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/FeedbackActivity.java
@@ -0,0 +1,54 @@
+/*
+ * 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.Activity;
+import android.os.Bundle;
+import android.widget.CheckBox;
+
+import com.android.inputmethod.latin.R;
+
+public class FeedbackActivity extends Activity {
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ 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
new file mode 100644
index 000000000..a2e08e2b7
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/FeedbackFragment.java
@@ -0,0 +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
+ *
+ * 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.Activity;
+import android.app.Fragment;
+import android.os.Bundle;
+import android.text.Editable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+
+import com.android.inputmethod.latin.R;
+
+public class FeedbackFragment extends Fragment {
+ private EditText mEditText;
+ private CheckBox mCheckBox;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ 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();
+ }
+ });
+
+ 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;
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/FeedbackLayout.java b/java/src/com/android/inputmethod/research/FeedbackLayout.java
new file mode 100644
index 000000000..f2cbfe308
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/FeedbackLayout.java
@@ -0,0 +1,62 @@
+/*
+ * 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.Activity;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.widget.LinearLayout;
+
+public class FeedbackLayout extends LinearLayout {
+ private Activity mActivity;
+
+ public FeedbackLayout(Context context) {
+ super(context);
+ }
+
+ public FeedbackLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public FeedbackLayout(Context context, AttributeSet attrs, int defstyle) {
+ super(context, attrs, defstyle);
+ }
+
+ public void setActivity(Activity activity) {
+ mActivity = activity;
+ }
+
+ @Override
+ public boolean dispatchKeyEventPreIme(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ KeyEvent.DispatcherState state = getKeyDispatcherState();
+ if (state != null) {
+ if (event.getAction() == KeyEvent.ACTION_DOWN
+ && event.getRepeatCount() == 0) {
+ state.startTracking(event, this);
+ return true;
+ } else if (event.getAction() == KeyEvent.ACTION_UP
+ && !event.isCanceled() && state.isTracking(event)) {
+ mActivity.onBackPressed();
+ return true;
+ }
+ }
+ }
+ return super.dispatchKeyEventPreIme(event);
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/LogBuffer.java b/java/src/com/android/inputmethod/research/LogBuffer.java
new file mode 100644
index 000000000..ae7b1579a
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogBuffer.java
@@ -0,0 +1,113 @@
+/*
+ * 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 com.android.inputmethod.latin.CollectionUtils;
+
+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.
+ */
+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;
+
+ /**
+ * 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.
+ }
+
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ }
+
+ /**
+ * Called to deliberately remove the oldest LogUnit. Usually called when draining the
+ * LogBuffer.
+ */
+ public LogUnit shiftOut() {
+ if (mLogUnits.isEmpty()) {
+ return null;
+ }
+ final LogUnit logUnit = mLogUnits.removeFirst();
+ if (logUnit.hasWord()) {
+ mNumActualWords--;
+ }
+ return logUnit;
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java
new file mode 100644
index 000000000..d8b3a29ff
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/LogUnit.java
@@ -0,0 +1,83 @@
+/*
+ * 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 com.android.inputmethod.latin.CollectionUtils;
+
+import java.util.ArrayList;
+
+/**
+ * A group of log statements related to each other.
+ *
+ * A LogUnit is collection of LogStatements, each of which is generated by at a particular point
+ * in the code. (There is no LogStatement class; the data is stored across the instance variables
+ * here.) A single LogUnit's statements can correspond to all the calls made while in the same
+ * composing region, or all the calls between committing the last composing region, and the first
+ * character of the next composing region.
+ *
+ * Individual statements in a log may be marked as potentially private. If so, then they are only
+ * published to a ResearchLog if the ResearchLogger determines that publishing the entire LogUnit
+ * will not violate the user's privacy. Checks for this may include whether other LogUnits have
+ * been published recently, or whether the LogUnit contains numbers, etc.
+ */
+/* package */ class LogUnit {
+ private final ArrayList<String[]> mKeysList = CollectionUtils.newArrayList();
+ private final ArrayList<Object[]> mValuesList = CollectionUtils.newArrayList();
+ private final ArrayList<Boolean> mIsPotentiallyPrivate = CollectionUtils.newArrayList();
+ private String mWord;
+ private boolean mContainsDigit;
+
+ public void addLogStatement(final String[] keys, final Object[] values,
+ final Boolean isPotentiallyPrivate) {
+ mKeysList.add(keys);
+ mValuesList.add(values);
+ mIsPotentiallyPrivate.add(isPotentiallyPrivate);
+ }
+
+ 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));
+ }
+ }
+ }
+
+ public void setWord(String word) {
+ mWord = word;
+ }
+
+ public String getWord() {
+ return mWord;
+ }
+
+ public boolean hasWord() {
+ return mWord != null;
+ }
+
+ public void setContainsDigit() {
+ mContainsDigit = true;
+ }
+
+ public boolean hasDigit() {
+ return mContainsDigit;
+ }
+
+ public boolean isEmpty() {
+ return mKeysList.isEmpty();
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/MainLogBuffer.java b/java/src/com/android/inputmethod/research/MainLogBuffer.java
new file mode 100644
index 000000000..745768d35
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/MainLogBuffer.java
@@ -0,0 +1,127 @@
+/*
+ * 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 com.android.inputmethod.latin.Dictionary;
+import com.android.inputmethod.latin.Suggest;
+
+import java.util.Random;
+
+public class MainLogBuffer extends LogBuffer {
+ // 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;
+
+ private final ResearchLog mResearchLog;
+ private Suggest mSuggest;
+
+ // 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;
+
+ // 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);
+ }
+
+ public void setSuggest(Suggest suggest) {
+ mSuggest = suggest;
+ }
+
+ @Override
+ public void shiftIn(final LogUnit newLogUnit) {
+ super.shiftIn(newLogUnit);
+ if (newLogUnit.hasWord()) {
+ if (mWordsUntilSafeToSample > 0) {
+ mWordsUntilSafeToSample--;
+ }
+ }
+ }
+
+ public void resetWordCounter() {
+ mWordsUntilSafeToSample = mMinWordPeriod;
+ }
+
+ /**
+ * Determines whether the content of the MainLogBuffer can be safely uploaded in its complete
+ * form and still protect the user's 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
+ * the buffer's entire content. If it is decided that the privacy risks are too great to upload
+ * the contents of this buffer, a censored version of the LogItems may still be uploaded. E.g.,
+ * the screen orientation and other characteristics about the device can be uploaded without
+ * revealing much about the user.
+ */
+ public boolean isSafeToLog() {
+ // 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.
+ return false;
+ }
+ // Reload the dictionary in case it has changed (e.g., because the user has changed
+ // languages).
+ final Dictionary dictionary = mSuggest.getMainDictionary();
+ if (dictionary == null) {
+ 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();
+ for (int i = 0; i < length; i++) {
+ final LogUnit logUnit = mLogUnits.get(i);
+ final String word = logUnit.getWord();
+ if (word == null) {
+ // Digits outside words are a privacy threat.
+ if (logUnit.hasDigit()) {
+ return false;
+ }
+ } else {
+ // Words not in the dictionary are a privacy threat.
+ if (!(dictionary.isValidWord(word))) {
+ return false;
+ }
+ }
+ }
+ // All checks have passed; this buffer's content can be safely uploaded.
+ return true;
+ }
+
+ @Override
+ protected void onShiftOut(LogUnit logUnit) {
+ if (mResearchLog != null) {
+ mResearchLog.publish(logUnit, false /* isIncludingPrivateData */);
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/ResearchLog.java b/java/src/com/android/inputmethod/research/ResearchLog.java
new file mode 100644
index 000000000..71a6d6a78
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/ResearchLog.java
@@ -0,0 +1,310 @@
+/*
+ * 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.os.SystemClock;
+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.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;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Logs the use of the LatinIME keyboard.
+ *
+ * This class logs operations on the IME keyboard, including what the user has typed.
+ * Data is stored locally in a file in app-specific storage.
+ *
+ * This functionality is off by default. See {@link ProductionFlag#IS_EXPERIMENTAL}.
+ */
+public class ResearchLog {
+ private static final String TAG = ResearchLog.class.getSimpleName();
+ private static final boolean DEBUG = false;
+ 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 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
+ // endObject, as well as beginArray and endArray, and the file is opened lazily, only when
+ // it is certain that data will be written. Alternatively, the matching call exceptions
+ // could be caught, but this might suppress other errors.
+ private boolean mHasWrittenData = false;
+
+ private static final JsonWriter NULL_JSON_WRITER = new JsonWriter(
+ new OutputStreamWriter(new NullOutputStream()));
+ private static class NullOutputStream extends OutputStream {
+ /** {@inheritDoc} */
+ @Override
+ public void write(byte[] buffer, int offset, int count) {
+ // nop
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void write(byte[] buffer) {
+ // nop
+ }
+
+ @Override
+ public void write(int oneByte) {
+ }
+ }
+
+ public ResearchLog(final File outputFile) {
+ if (outputFile == null) {
+ throw new IllegalArgumentException();
+ }
+ mExecutor = Executors.newSingleThreadScheduledExecutor();
+ mFile = outputFile;
+ }
+
+ public synchronized void close() {
+ mExecutor.submit(new Callable<Object>() {
+ @Override
+ public Object call() throws Exception {
+ try {
+ if (mHasWrittenData) {
+ mJsonWriter.endArray();
+ mJsonWriter.flush();
+ mJsonWriter.close();
+ mHasWrittenData = false;
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "error when closing ResearchLog:");
+ e.printStackTrace();
+ } finally {
+ if (mFile.exists()) {
+ mFile.setWritable(false, false);
+ }
+ }
+ return null;
+ }
+ });
+ removeAnyScheduledFlush();
+ mExecutor.shutdown();
+ }
+
+ private boolean mIsAbortSuccessful;
+
+ public synchronized void abort() {
+ mExecutor.submit(new Callable<Object>() {
+ @Override
+ public Object call() throws Exception {
+ try {
+ if (mHasWrittenData) {
+ mJsonWriter.endArray();
+ mJsonWriter.close();
+ mHasWrittenData = false;
+ }
+ } finally {
+ mIsAbortSuccessful = mFile.delete();
+ }
+ return null;
+ }
+ });
+ removeAnyScheduledFlush();
+ mExecutor.shutdown();
+ }
+
+ public boolean blockingAbort() throws InterruptedException {
+ abort();
+ mExecutor.awaitTermination(ABORT_TIMEOUT_IN_MS, TimeUnit.MILLISECONDS);
+ return mIsAbortSuccessful;
+ }
+
+ public void awaitTermination(int delay, TimeUnit timeUnit) throws InterruptedException {
+ mExecutor.awaitTermination(delay, timeUnit);
+ }
+
+ /* package */ synchronized void flush() {
+ removeAnyScheduledFlush();
+ mExecutor.submit(mFlushCallable);
+ }
+
+ private final Callable<Object> mFlushCallable = new Callable<Object>() {
+ @Override
+ public Object call() throws Exception {
+ mJsonWriter.flush();
+ return null;
+ }
+ };
+
+ private ScheduledFuture<Object> mFlushFuture;
+
+ private void removeAnyScheduledFlush() {
+ if (mFlushFuture != null) {
+ mFlushFuture.cancel(false);
+ mFlushFuture = null;
+ }
+ }
+
+ private void scheduleFlush() {
+ removeAnyScheduledFlush();
+ mFlushFuture = mExecutor.schedule(mFlushCallable, FLUSH_DELAY_IN_MS, TimeUnit.MILLISECONDS);
+ }
+
+ public synchronized void publish(final LogUnit logUnit, final boolean isIncludingPrivateData) {
+ try {
+ mExecutor.submit(new Callable<Object>() {
+ @Override
+ public Object call() throws Exception {
+ logUnit.publishTo(ResearchLog.this, isIncludingPrivateData);
+ scheduleFlush();
+ return null;
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ // TODO: Add code to record loss of data, and report.
+ }
+ }
+
+ 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]);
+ }
+ }
+ try {
+ if (mJsonWriter == NULL_JSON_WRITER) {
+ mJsonWriter = new JsonWriter(new BufferedWriter(new FileWriter(mFile)));
+ mJsonWriter.beginArray();
+ 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.mAltCode);
+ 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");
+ try {
+ mJsonWriter.close();
+ } catch (IllegalStateException e1) {
+ // Assume that this is just the json not being terminated properly.
+ // Ignore
+ } catch (IOException e1) {
+ e1.printStackTrace();
+ } finally {
+ mJsonWriter = NULL_JSON_WRITER;
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java
new file mode 100644
index 000000000..918fcf5a1
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/ResearchLogger.java
@@ -0,0 +1,1270 @@
+/*
+ * 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 static com.android.inputmethod.latin.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET;
+
+import android.app.AlarmManager;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.inputmethodservice.InputMethodService;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+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.Button;
+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.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.LatinIME;
+import com.android.inputmethod.latin.R;
+import com.android.inputmethod.latin.RichInputConnection;
+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 java.io.File;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.UUID;
+
+/**
+ * Logs the use of the LatinIME keyboard.
+ *
+ * This class logs operations on the IME keyboard, including what the user has typed.
+ * Data is stored locally in a file in app-specific storage.
+ *
+ * This functionality is off by default. See {@link ProductionFlag#IS_EXPERIMENTAL}.
+ */
+public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = ResearchLogger.class.getSimpleName();
+ private static final boolean OUTPUT_ENTIRE_BUFFER = false; // true may disclose private info
+ 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 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";
+ private static final SimpleDateFormat TIMESTAMP_DATEFORMAT =
+ new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
+ 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;
+
+ // 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 ResearchLogger sInstance = new ResearchLogger();
+ // to write to a different filename, e.g., for testing, set mFile before calling start()
+ /* package */ File mFilesDir;
+ /* package */ String mUUIDString;
+ /* package */ ResearchLog mMainResearchLog;
+ // mFeedbackLog records all events for the session, private or not (excepting
+ // passwords). It is written to permanent storage only if the user explicitly commands
+ // 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;
+ /* package */ LogBuffer mFeedbackLogBuffer;
+
+ private boolean mIsPasswordView = false;
+ private boolean mIsLoggingSuspended = false;
+ private SharedPreferences mPrefs;
+
+ // digits entered by the user are replaced with this codepoint.
+ /* package for test */ static final int DIGIT_REPLACEMENT_CODEPOINT =
+ Character.codePointAt("\uE000", 0); // U+E000 is in the "private-use area"
+ // U+E001 is in the "private-use area"
+ /* package for test */ static final String WORD_REPLACEMENT_STRING = "\uE001";
+ private static final String PREF_LAST_CLEANUP_TIME = "pref_last_cleanup_time";
+ private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS;
+ private static final long MAX_LOGFILE_AGE_IN_MS = 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
+ private static boolean sLatinIMEExpectingUpdateSelection = false;
+
+ // used to check whether words are not unique
+ private Suggest mSuggest;
+ private Dictionary mDictionary;
+ private MainKeyboardView mMainKeyboardView;
+ private InputMethodService mInputMethodService;
+ private final Statistics mStatistics;
+
+ private Intent mUploadIntent;
+ private PendingIntent mUploadPendingIntent;
+
+ private LogUnit mCurrentLogUnit = new LogUnit();
+
+ private ResearchLogger() {
+ mStatistics = Statistics.getInstance();
+ }
+
+ public static ResearchLogger getInstance() {
+ return sInstance;
+ }
+
+ public void init(final InputMethodService ims, final SharedPreferences prefs) {
+ assert ims != null;
+ if (ims == null) {
+ Log.w(TAG, "IMS is null; logging is off");
+ } else {
+ mFilesDir = ims.getFilesDir();
+ if (mFilesDir == null || !mFilesDir.exists()) {
+ Log.w(TAG, "IME storage directory does not exist.");
+ }
+ }
+ if (prefs != null) {
+ mUUIDString = getUUID(prefs);
+ if (!prefs.contains(PREF_USABILITY_STUDY_MODE)) {
+ Editor e = prefs.edit();
+ e.putBoolean(PREF_USABILITY_STUDY_MODE, DEFAULT_USABILITY_STUDY_MODE);
+ e.apply();
+ }
+ sIsLogging = prefs.getBoolean(PREF_USABILITY_STUDY_MODE, false);
+ prefs.registerOnSharedPreferenceChangeListener(this);
+
+ final long lastCleanupTime = prefs.getLong(PREF_LAST_CLEANUP_TIME, 0L);
+ final long now = System.currentTimeMillis();
+ if (lastCleanupTime + DURATION_BETWEEN_DIR_CLEANUP_IN_MS < now) {
+ final long timeHorizon = now - MAX_LOGFILE_AGE_IN_MS;
+ cleanupLoggingDir(mFilesDir, timeHorizon);
+ Editor e = prefs.edit();
+ e.putLong(PREF_LAST_CLEANUP_TIME, now);
+ e.apply();
+ }
+ }
+ mInputMethodService = ims;
+ mPrefs = prefs;
+ mUploadIntent = new Intent(mInputMethodService, UploaderService.class);
+ mUploadPendingIntent = PendingIntent.getService(mInputMethodService, 0, mUploadIntent, 0);
+
+ if (ProductionFlag.IS_EXPERIMENTAL) {
+ scheduleUploadingService(mInputMethodService);
+ }
+ }
+
+ /**
+ * Arrange for the UploaderService to be run on a regular basis.
+ *
+ * Any existing scheduled invocation of UploaderService is removed and rescheduled. This may
+ * cause problems if this method is called often and frequent updates are required, but since
+ * the user will likely be sleeping at some point, if the interval is less that the expected
+ * sleep duration and this method is not called during that time, the service should be invoked
+ * at some point.
+ */
+ public static void scheduleUploadingService(Context context) {
+ final Intent intent = new Intent(context, UploaderService.class);
+ final PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, 0);
+ final AlarmManager manager =
+ (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ manager.cancel(pendingIntent);
+ manager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ UploaderService.RUN_INTERVAL, UploaderService.RUN_INTERVAL, pendingIntent);
+ }
+
+ private void cleanupLoggingDir(final File dir, final long time) {
+ for (File file : dir.listFiles()) {
+ if (file.getName().startsWith(ResearchLogger.FILENAME_PREFIX) &&
+ file.lastModified() < time) {
+ file.delete();
+ }
+ }
+ }
+
+ public void mainKeyboardView_onAttachedToWindow(final MainKeyboardView mainKeyboardView) {
+ mMainKeyboardView = mainKeyboardView;
+ maybeShowSplashScreen();
+ }
+
+ public void mainKeyboardView_onDetachedFromWindow() {
+ mMainKeyboardView = null;
+ }
+
+ private boolean hasSeenSplash() {
+ return mPrefs.getBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, false);
+ }
+
+ private Dialog mSplashDialog = null;
+
+ private void maybeShowSplashScreen() {
+ if (hasSeenSplash()) {
+ return;
+ }
+ if (mSplashDialog != null && mSplashDialog.isShowing()) {
+ return;
+ }
+ final IBinder windowToken = mMainKeyboardView != null
+ ? mMainKeyboardView.getWindowToken() : null;
+ if (windowToken == null) {
+ return;
+ }
+ mSplashDialog = new Dialog(mInputMethodService, android.R.style.Theme_Holo_Dialog);
+ mSplashDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
+ mSplashDialog.setContentView(R.layout.research_splash);
+ mSplashDialog.setCancelable(true);
+ final Window w = mSplashDialog.getWindow();
+ final WindowManager.LayoutParams lp = w.getAttributes();
+ lp.token = windowToken;
+ lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG;
+ w.setAttributes(lp);
+ w.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
+ mSplashDialog.setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ mInputMethodService.requestHideSelf(0);
+ }
+ });
+ final Button doNotLogButton = (Button) mSplashDialog.findViewById(
+ R.id.research_do_not_log_button);
+ doNotLogButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onUserLoggingElection(false);
+ mSplashDialog.dismiss();
+ }
+ });
+ final Button doLogButton = (Button) mSplashDialog.findViewById(R.id.research_do_log_button);
+ doLogButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onUserLoggingElection(true);
+ mSplashDialog.dismiss();
+ }
+ });
+ mSplashDialog.show();
+ }
+
+ public void onUserLoggingElection(final boolean enableLogging) {
+ setLoggingAllowed(enableLogging);
+ if (mPrefs == null) {
+ return;
+ }
+ final Editor e = mPrefs.edit();
+ e.putBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, true);
+ e.apply();
+ restart();
+ }
+
+ private void setLoggingAllowed(boolean enableLogging) {
+ if (mPrefs == null) {
+ return;
+ }
+ Editor e = mPrefs.edit();
+ e.putBoolean(PREF_USABILITY_STUDY_MODE, enableLogging);
+ e.apply();
+ sIsLogging = enableLogging;
+ }
+
+ private File createLogFile(File filesDir) {
+ final StringBuilder sb = new StringBuilder();
+ sb.append(FILENAME_PREFIX).append('-');
+ sb.append(mUUIDString).append('-');
+ sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
+ sb.append(FILENAME_SUFFIX);
+ return new File(filesDir, sb.toString());
+ }
+
+ private void checkForEmptyEditor() {
+ if (mInputMethodService == null) {
+ return;
+ }
+ final InputConnection ic = mInputMethodService.getCurrentInputConnection();
+ if (ic == null) {
+ return;
+ }
+ final CharSequence textBefore = ic.getTextBeforeCursor(1, 0);
+ if (!TextUtils.isEmpty(textBefore)) {
+ mStatistics.setIsEmptyUponStarting(false);
+ return;
+ }
+ final CharSequence textAfter = ic.getTextAfterCursor(1, 0);
+ if (!TextUtils.isEmpty(textAfter)) {
+ mStatistics.setIsEmptyUponStarting(false);
+ return;
+ }
+ if (textBefore != null && textAfter != null) {
+ mStatistics.setIsEmptyUponStarting(true);
+ }
+ }
+
+ private void start() {
+ maybeShowSplashScreen();
+ updateSuspendedState();
+ requestIndicatorRedraw();
+ mStatistics.reset();
+ checkForEmptyEditor();
+ if (!isAllowedToLog()) {
+ // Log.w(TAG, "not in usability mode; not logging");
+ return;
+ }
+ if (mFilesDir == null || !mFilesDir.exists()) {
+ Log.w(TAG, "IME storage directory does not exist. Cannot start logging.");
+ return;
+ }
+ if (mMainLogBuffer == null) {
+ mMainResearchLog = new ResearchLog(createLogFile(mFilesDir));
+ mMainLogBuffer = new MainLogBuffer(mMainResearchLog);
+ 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);
+ }
+ }
+
+ /* package */ void stop() {
+ logStatistics();
+ commitCurrentLogUnit();
+
+ if (mMainLogBuffer != null) {
+ publishLogBuffer(mMainLogBuffer, mMainResearchLog, false /* isIncludingPrivateData */);
+ mMainResearchLog.close();
+ mMainLogBuffer = null;
+ }
+ if (mFeedbackLogBuffer != null) {
+ mFeedbackLog.close();
+ mFeedbackLogBuffer = null;
+ }
+ }
+
+ public boolean abort() {
+ boolean didAbortMainLog = false;
+ if (mMainLogBuffer != null) {
+ mMainLogBuffer.clear();
+ try {
+ didAbortMainLog = mMainResearchLog.blockingAbort();
+ } catch (InterruptedException e) {
+ // Don't know whether this succeeded or not. We assume not; this is reported
+ // to the caller.
+ }
+ mMainLogBuffer = null;
+ }
+ boolean didAbortFeedbackLog = false;
+ if (mFeedbackLogBuffer != null) {
+ mFeedbackLogBuffer.clear();
+ try {
+ didAbortFeedbackLog = mFeedbackLog.blockingAbort();
+ } catch (InterruptedException e) {
+ // Don't know whether this succeeded or not. We assume not; this is reported
+ // to the caller.
+ }
+ mFeedbackLogBuffer = null;
+ }
+ return didAbortMainLog && didAbortFeedbackLog;
+ }
+
+ private void restart() {
+ stop();
+ start();
+ }
+
+ 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) {
+ mIsLoggingSuspended = false;
+ }
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
+ if (key == null || prefs == null) {
+ return;
+ }
+ sIsLogging = prefs.getBoolean(PREF_USABILITY_STUDY_MODE, false);
+ if (sIsLogging == false) {
+ abort();
+ }
+ requestIndicatorRedraw();
+ mPrefs = prefs;
+ prefsChanged(prefs);
+ }
+
+ public void presentResearchDialog(final LatinIME latinIME) {
+ if (mInFeedbackDialog) {
+ Toast.makeText(latinIME, R.string.research_please_exit_feedback_form,
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ 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:
+ if (showEnable) {
+ if (!sIsLogging) {
+ setLoggingAllowed(true);
+ }
+ resumeLogging();
+ Toast.makeText(latinIME,
+ R.string.research_notify_session_logging_enabled,
+ Toast.LENGTH_LONG).show();
+ } else {
+ Toast toast = Toast.makeText(latinIME,
+ R.string.research_notify_session_log_deleting,
+ Toast.LENGTH_LONG);
+ toast.show();
+ boolean isLogDeleted = abort();
+ final long currentTime = System.currentTimeMillis();
+ final long resumeTime = currentTime + 1000 * 60 *
+ SUSPEND_DURATION_IN_MINUTES;
+ suspendLoggingUntil(resumeTime);
+ toast.cancel();
+ Toast.makeText(latinIME, R.string.research_notify_logging_suspended,
+ Toast.LENGTH_LONG).show();
+ }
+ break;
+ }
+ }
+
+ };
+ final AlertDialog.Builder builder = new AlertDialog.Builder(latinIME)
+ .setItems(items, listener)
+ .setTitle(title);
+ latinIME.showOptionDialog(builder.create());
+ }
+
+ private boolean mInFeedbackDialog = false;
+ public void presentFeedbackDialog(LatinIME latinIME) {
+ mInFeedbackDialog = true;
+ latinIME.launchKeyboardedDialogActivity(FeedbackActivity.class);
+ }
+
+ private static final String[] EVENTKEYS_FEEDBACK = {
+ "UserTimestamp", "contents"
+ };
+ public void sendFeedback(final String feedbackContents, final boolean includeHistory) {
+ if (mFeedbackLogBuffer == null) {
+ return;
+ }
+ if (includeHistory) {
+ commitCurrentLogUnit();
+ } else {
+ mFeedbackLogBuffer.clear();
+ }
+ final LogUnit feedbackLogUnit = new LogUnit();
+ final Object[] values = {
+ feedbackContents
+ };
+ feedbackLogUnit.addLogStatement(EVENTKEYS_FEEDBACK, values,
+ false /* isPotentiallyPrivate */);
+ mFeedbackLogBuffer.shiftIn(feedbackLogUnit);
+ publishLogBuffer(mFeedbackLogBuffer, mFeedbackLog, true /* isIncludingPrivateData */);
+ mFeedbackLog.close();
+ uploadNow();
+ mFeedbackLog = new ResearchLog(createLogFile(mFilesDir));
+ }
+
+ public void uploadNow() {
+ mInputMethodService.startService(mUploadIntent);
+ }
+
+ public void onLeavingSendFeedbackDialog() {
+ mInFeedbackDialog = false;
+ }
+
+ public void initSuggest(Suggest suggest) {
+ mSuggest = suggest;
+ if (mMainLogBuffer != null) {
+ mMainLogBuffer.setSuggest(mSuggest);
+ }
+ }
+
+ private void setIsPasswordView(boolean isPasswordView) {
+ mIsPasswordView = isPasswordView;
+ }
+
+ private boolean isAllowedToLog() {
+ return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog;
+ }
+
+ public void requestIndicatorRedraw() {
+ if (!IS_SHOWING_INDICATOR) {
+ return;
+ }
+ if (mMainKeyboardView == null) {
+ return;
+ }
+ mMainKeyboardView.invalidateAllKeys();
+ }
+
+
+ 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) {
+ final int savedColor = paint.getColor();
+ paint.setColor(Color.RED);
+ 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);
+ } 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.
+ paint.setStrokeWidth(3);
+ canvas.drawRect(width, height, width, height, paint);
+ }
+ paint.setColor(savedColor);
+ paint.setStyle(savedStyle);
+ paint.setStrokeWidth(savedStrokeWidth);
+ }
+ }
+
+ 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 void setCurrentLogUnitContainsDigitFlag() {
+ mCurrentLogUnit.setContainsDigit();
+ }
+
+ /**
+ * 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 */);
+ }
+ }
+
+ /* package for test */ void commitCurrentLogUnit() {
+ 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);
+ }
+ mCurrentLogUnit = new LogUnit();
+ Log.d(TAG, "commitCurrentLogUnit");
+ }
+ }
+
+ /* 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);
+ }
+ }
+
+ private boolean hasOnlyLetters(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;
+ }
+ }
+ return true;
+ }
+
+ private void onWordComplete(final String word) {
+ Log.d(TAG, "onWordComplete: " + word);
+ if (word != null && word.length() > 0 && hasOnlyLetters(word)) {
+ mCurrentLogUnit.setWord(word);
+ mStatistics.recordWordEntered();
+ }
+ commitCurrentLogUnit();
+ }
+
+ private static int scrubDigitFromCodePoint(int codePoint) {
+ return Character.isDigit(codePoint) ? DIGIT_REPLACEMENT_CODEPOINT : codePoint;
+ }
+
+ /* package for test */ static String scrubDigitsFromString(String s) {
+ StringBuilder sb = null;
+ final int length = s.length();
+ for (int i = 0; i < length; i = s.offsetByCodePoints(i, 1)) {
+ final int codePoint = Character.codePointAt(s, i);
+ if (Character.isDigit(codePoint)) {
+ if (sb == null) {
+ sb = new StringBuilder(length);
+ sb.append(s.substring(0, i));
+ }
+ sb.appendCodePoint(DIGIT_REPLACEMENT_CODEPOINT);
+ } else {
+ if (sb != null) {
+ sb.appendCodePoint(codePoint);
+ }
+ }
+ }
+ if (sb == null) {
+ return s;
+ } else {
+ return sb.toString();
+ }
+ }
+
+ private static String getUUID(final SharedPreferences prefs) {
+ String uuidString = prefs.getString(PREF_RESEARCH_LOGGER_UUID_STRING, null);
+ if (null == uuidString) {
+ UUID uuid = UUID.randomUUID();
+ uuidString = uuid.toString();
+ Editor editor = prefs.edit();
+ editor.putString(PREF_RESEARCH_LOGGER_UUID_STRING, uuidString);
+ editor.apply();
+ }
+ return uuidString;
+ }
+
+ private String scrubWord(String word) {
+ if (mDictionary == null) {
+ return WORD_REPLACEMENT_STRING;
+ }
+ if (mDictionary.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"
+ };
+ 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;
+ 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.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);
+ } catch (NameNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public void latinIME_onFinishInputInternal() {
+ stop();
+ }
+
+ private static final String[] EVENTKEYS_USER_FEEDBACK = {
+ "UserFeedback", "FeedbackContents"
+ };
+
+ private static final String[] EVENTKEYS_PREFS_CHANGED = {
+ "PrefsChanged", "prefs"
+ };
+ public static void prefsChanged(final SharedPreferences prefs) {
+ final ResearchLogger researchLogger = getInstance();
+ final Object[] values = {
+ prefs
+ };
+ researchLogger.enqueueEvent(EVENTKEYS_PREFS_CHANGED, values);
+ }
+
+ // Regular logging methods
+
+ private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_PROCESSMOTIONEVENT = {
+ "MainKeyboardViewProcessMotionEvent", "action", "eventTime", "id", "x", "y", "size",
+ "pressure"
+ };
+ 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 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"
+ };
+ 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);
+ if (Character.isDigit(code)) {
+ researchLogger.setCurrentLogUnitContainsDigitFlag();
+ }
+ researchLogger.mStatistics.recordChar(code, time);
+ }
+
+ private static final String[] EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS = {
+ "LatinIMEOnDisplayCompletions", "applicationSpecifiedCompletions"
+ };
+ public static void latinIME_onDisplayCompletions(
+ final CompletionInfo[] applicationSpecifiedCompletions) {
+ final Object[] values = {
+ applicationSpecifiedCompletions
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_ONDISPLAYCOMPLETIONS,
+ values);
+ }
+
+ public static boolean getAndClearLatinIMEExpectingUpdateSelection() {
+ boolean returnValue = sLatinIMEExpectingUpdateSelection;
+ sLatinIMEExpectingUpdateSelection = false;
+ return returnValue;
+ }
+
+ private static final String[] EVENTKEYS_LATINIME_ONWINDOWHIDDEN = {
+ "LatinIMEOnWindowHidden", "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) {
+ if (TextUtils.isEmpty(charSequence)) {
+ values[0] = false;
+ values[1] = "";
+ } else {
+ if (charSequence.length() > MAX_INPUTVIEW_LENGTH_TO_CAPTURE) {
+ int length = MAX_INPUTVIEW_LENGTH_TO_CAPTURE;
+ // do not cut in the middle of a supplementary character
+ final char c = charSequence.charAt(length - 1);
+ if (Character.isHighSurrogate(c)) {
+ length--;
+ }
+ final CharSequence truncatedCharSequence = charSequence.subSequence(0,
+ length);
+ values[0] = true;
+ values[1] = truncatedCharSequence.toString();
+ } else {
+ values[0] = false;
+ values[1] = charSequence.toString();
+ }
+ }
+ } else {
+ values[0] = true;
+ values[1] = "";
+ }
+ final ResearchLogger researchLogger = getInstance();
+ researchLogger.enqueueEvent(EVENTKEYS_LATINIME_ONWINDOWHIDDEN, values);
+ researchLogger.commitCurrentLogUnit();
+ getInstance().stop();
+ }
+ }
+
+ private static final String[] EVENTKEYS_LATINIME_ONUPDATESELECTION = {
+ "LatinIMEOnUpdateSelection", "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,
+ final int composingSpanEnd, final boolean expectingUpdateSelection,
+ final boolean expectingUpdateSelectionFromLogger,
+ final RichInputConnection connection) {
+ String word = "";
+ if (connection != null) {
+ Range range = connection.getWordRangeAtCursor(WHITESPACE_SEPARATORS, 1);
+ if (range != null) {
+ word = range.mWord;
+ }
+ }
+ 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);
+ }
+
+ private static final String[] EVENTKEYS_LATINIME_PICKSUGGESTIONMANUALLY = {
+ "LatinIMEPickSuggestionManually", "replacedWord", "index", "suggestion", "x", "y"
+ };
+ 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 ResearchLogger researchLogger = getInstance();
+ researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_LATINIME_PICKSUGGESTIONMANUALLY,
+ values);
+ }
+
+ 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);
+ }
+
+ private static final String[] EVENTKEYS_LATINIME_SENDKEYCODEPOINT = {
+ "LatinIMESendKeyCodePoint", "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);
+ 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);
+ }
+
+ private static final String[] EVENTKEYS_MAINKEYBOARDVIEW_ONLONGPRESS = {
+ "MainKeyboardViewOnLongPress"
+ };
+ public static void mainKeyboardView_onLongPress() {
+ getInstance().enqueueEvent(EVENTKEYS_MAINKEYBOARDVIEW_ONLONGPRESS, EVENTKEYS_NULLVALUES);
+ }
+
+ 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"
+ };
+ 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 = {
+ 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().enqueueEvent(EVENTKEYS_MAINKEYBOARDVIEW_SETKEYBOARD, values);
+ getInstance().setIsPasswordView(isPasswordView);
+ }
+ }
+
+ 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);
+ }
+
+ private static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONCANCELINPUT = {
+ "PointerTrackerCallListenerOnCancelInput"
+ };
+ public static void pointerTracker_callListenerOnCancelInput() {
+ getInstance().enqueueEvent(EVENTKEYS_POINTERTRACKER_CALLLISTENERONCANCELINPUT,
+ EVENTKEYS_NULLVALUES);
+ }
+
+ private static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONCODEINPUT = {
+ "PointerTrackerCallListenerOnCodeInput", "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) {
+ CharSequence outputText = key.mOutputText;
+ 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);
+ }
+ }
+
+ private static final String[] EVENTKEYS_POINTERTRACKER_CALLLISTENERONRELEASE = {
+ "PointerTrackerCallListenerOnRelease", "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);
+ }
+ }
+
+ private static final String[] EVENTKEYS_POINTERTRACKER_ONDOWNEVENT = {
+ "PointerTrackerOnDownEvent", "deltaT", "distanceSquared"
+ };
+ public static void pointerTracker_onDownEvent(long deltaT, int distanceSquared) {
+ final Object[] values = {
+ deltaT, distanceSquared
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_POINTERTRACKER_ONDOWNEVENT, values);
+ }
+
+ private static final String[] EVENTKEYS_POINTERTRACKER_ONMOVEEVENT = {
+ "PointerTrackerOnMoveEvent", "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);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_COMMITCOMPLETION = {
+ "RichInputConnectionCommitCompletion", "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)
+ };
+ final ResearchLogger researchLogger = getInstance();
+ researchLogger.enqueuePotentiallyPrivateEvent(
+ EVENTKEYS_RICHINPUTCONNECTION_COMMITCORRECTION, values);
+ */
+ }
+
+ 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
+ };
+ final ResearchLogger researchLogger = getInstance();
+ researchLogger.enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_COMMITTEXT,
+ values);
+ researchLogger.onWordComplete(scrubbedWord);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT = {
+ "RichInputConnectionDeleteSurroundingText", "beforeLength", "afterLength"
+ };
+ public static void richInputConnection_deleteSurroundingText(final int beforeLength,
+ final int afterLength) {
+ final Object[] values = {
+ beforeLength, afterLength
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(
+ EVENTKEYS_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT, values);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT = {
+ "RichInputConnectionFinishComposingText"
+ };
+ public static void richInputConnection_finishComposingText() {
+ getInstance().enqueueEvent(EVENTKEYS_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT,
+ EVENTKEYS_NULLVALUES);
+ }
+
+ 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);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SENDKEYEVENT = {
+ "RichInputConnectionSendKeyEvent", "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);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETCOMPOSINGTEXT = {
+ "RichInputConnectionSetComposingText", "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);
+ }
+
+ private static final String[] EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION = {
+ "RichInputConnectionSetSelection", "from", "to"
+ };
+ public static void richInputConnection_setSelection(final int from, final int to) {
+ final Object[] values = {
+ from, to
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(EVENTKEYS_RICHINPUTCONNECTION_SETSELECTION,
+ values);
+ }
+
+ private static final String[] EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT = {
+ "SuddenJumpingTouchEventHandlerOnTouchEvent", "motionEvent"
+ };
+ public static void suddenJumpingTouchEventHandler_onTouchEvent(final MotionEvent me) {
+ if (me != null) {
+ final Object[] values = {
+ me.toString()
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(
+ EVENTKEYS_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT, values);
+ }
+ }
+
+ private static final String[] EVENTKEYS_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS = {
+ "SuggestionStripViewSetSuggestions", "suggestedWords"
+ };
+ public static void suggestionStripView_setSuggestions(final SuggestedWords suggestedWords) {
+ if (suggestedWords != null) {
+ final Object[] values = {
+ suggestedWords
+ };
+ getInstance().enqueuePotentiallyPrivateEvent(
+ EVENTKEYS_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS, values);
+ }
+ }
+
+ private static final String[] EVENTKEYS_USER_TIMESTAMP = {
+ "UserTimestamp"
+ };
+ public void userTimestamp() {
+ getInstance().enqueueEvent(EVENTKEYS_USER_TIMESTAMP, EVENTKEYS_NULLVALUES);
+ }
+
+ private static final String[] EVENTKEYS_STATISTICS = {
+ "Statistics", "charCount", "letterCount", "numberCount", "spaceCount", "deleteOpsCount",
+ "wordCount", "isEmptyUponStarting", "isEmptinessStateKnown", "averageTimeBetweenKeys",
+ "averageTimeBeforeDelete", "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete"
+ };
+ 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);
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/Statistics.java b/java/src/com/android/inputmethod/research/Statistics.java
new file mode 100644
index 000000000..eab465aa2
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/Statistics.java
@@ -0,0 +1,146 @@
+/*
+ * 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 com.android.inputmethod.keyboard.Keyboard;
+
+public class Statistics {
+ // Number of characters entered during a typing session
+ int mCharCount;
+ // Number of letter characters entered during a typing session
+ int mLetterCount;
+ // Number of number characters entered
+ int mNumberCount;
+ // Number of space characters entered
+ int mSpaceCount;
+ // Number of delete operations entered (taps on the backspace key)
+ int mDeleteKeyCount;
+ // Number of words entered during a session.
+ int mWordCount;
+ // Whether the text field was empty upon editing
+ boolean mIsEmptyUponStarting;
+ boolean mIsEmptinessStateKnown;
+
+ // Timers to count average time to enter a key, first press a delete key,
+ // between delete keys, and then to return typing after a delete key.
+ final AverageTimeCounter mKeyCounter = new AverageTimeCounter();
+ final AverageTimeCounter mBeforeDeleteKeyCounter = new AverageTimeCounter();
+ final AverageTimeCounter mDuringRepeatedDeleteKeysCounter = new AverageTimeCounter();
+ final AverageTimeCounter mAfterDeleteKeyCounter = new AverageTimeCounter();
+
+ static class AverageTimeCounter {
+ int mCount;
+ int mTotalTime;
+
+ public void reset() {
+ mCount = 0;
+ mTotalTime = 0;
+ }
+
+ public void add(long deltaTime) {
+ mCount++;
+ mTotalTime += deltaTime;
+ }
+
+ public int getAverageTime() {
+ if (mCount == 0) {
+ return 0;
+ }
+ return mTotalTime / mCount;
+ }
+ }
+
+ // To account for the interruptions when the user's attention is directed elsewhere, times
+ // longer than MIN_TYPING_INTERMISSION are not counted when estimating this statistic.
+ public static final int MIN_TYPING_INTERMISSION = 2 * 1000; // in milliseconds
+ public static final int MIN_DELETION_INTERMISSION = 10 * 1000; // in milliseconds
+
+ // The last time that a tap was performed
+ private long mLastTapTime;
+ // The type of the last keypress (delete key or not)
+ boolean mIsLastKeyDeleteKey;
+
+ private static final Statistics sInstance = new Statistics();
+
+ public static Statistics getInstance() {
+ return sInstance;
+ }
+
+ private Statistics() {
+ reset();
+ }
+
+ public void reset() {
+ mCharCount = 0;
+ mLetterCount = 0;
+ mNumberCount = 0;
+ mSpaceCount = 0;
+ mDeleteKeyCount = 0;
+ mWordCount = 0;
+ mIsEmptyUponStarting = true;
+ mIsEmptinessStateKnown = false;
+ mKeyCounter.reset();
+ mBeforeDeleteKeyCounter.reset();
+ mDuringRepeatedDeleteKeysCounter.reset();
+ mAfterDeleteKeyCounter.reset();
+
+ mLastTapTime = 0;
+ mIsLastKeyDeleteKey = false;
+ }
+
+ public void recordChar(int codePoint, long time) {
+ final long delta = time - mLastTapTime;
+ if (codePoint == Keyboard.CODE_DELETE) {
+ mDeleteKeyCount++;
+ if (delta < MIN_DELETION_INTERMISSION) {
+ if (mIsLastKeyDeleteKey) {
+ mDuringRepeatedDeleteKeysCounter.add(delta);
+ } else {
+ mBeforeDeleteKeyCounter.add(delta);
+ }
+ }
+ mIsLastKeyDeleteKey = true;
+ } else {
+ mCharCount++;
+ if (Character.isDigit(codePoint)) {
+ mNumberCount++;
+ }
+ if (Character.isLetter(codePoint)) {
+ mLetterCount++;
+ }
+ 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;
+ }
+ mLastTapTime = time;
+ }
+
+ public void recordWordEntered() {
+ mWordCount++;
+ }
+
+ public void setIsEmptyUponStarting(final boolean isEmpty) {
+ mIsEmptyUponStarting = isEmpty;
+ mIsEmptinessStateKnown = true;
+ }
+}
diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java
new file mode 100644
index 000000000..7a5749096
--- /dev/null
+++ b/java/src/com/android/inputmethod/research/UploaderService.java
@@ -0,0 +1,191 @@
+/*
+ * 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.Manifest;
+import android.app.AlarmManager;
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.BatteryManager;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.inputmethod.latin.R;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+public final class UploaderService extends IntentService {
+ private static final String TAG = UploaderService.class.getSimpleName();
+ public static final long RUN_INTERVAL = AlarmManager.INTERVAL_HOUR;
+ private static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName()
+ + ".extra.UPLOAD_UNCONDITIONALLY";
+ private static final int BUF_SIZE = 1024 * 8;
+ protected static final int TIMEOUT_IN_MS = 1000 * 4;
+
+ private boolean mCanUpload;
+ private File mFilesDir;
+ private URL mUrl;
+
+ public UploaderService() {
+ super("Research Uploader Service");
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ mCanUpload = false;
+ mFilesDir = null;
+ mUrl = null;
+
+ final PackageManager packageManager = getPackageManager();
+ final boolean hasPermission = packageManager.checkPermission(Manifest.permission.INTERNET,
+ getPackageName()) == PackageManager.PERMISSION_GRANTED;
+ if (!hasPermission) {
+ return;
+ }
+
+ try {
+ final String urlString = getString(R.string.research_logger_upload_url);
+ if (urlString == null || urlString.equals("")) {
+ return;
+ }
+ mFilesDir = getFilesDir();
+ mUrl = new URL(urlString);
+ mCanUpload = true;
+ } catch (MalformedURLException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ protected void onHandleIntent(Intent intent) {
+ if (!mCanUpload) {
+ return;
+ }
+ boolean isUploadingUnconditionally = false;
+ Bundle bundle = intent.getExtras();
+ if (bundle != null && bundle.containsKey(EXTRA_UPLOAD_UNCONDITIONALLY)) {
+ isUploadingUnconditionally = bundle.getBoolean(EXTRA_UPLOAD_UNCONDITIONALLY);
+ }
+ doUpload(isUploadingUnconditionally);
+ }
+
+ private boolean isExternallyPowered() {
+ final Intent intent = registerReceiver(null, new IntentFilter(
+ Intent.ACTION_BATTERY_CHANGED));
+ final int pluggedState = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
+ return pluggedState == BatteryManager.BATTERY_PLUGGED_AC
+ || pluggedState == BatteryManager.BATTERY_PLUGGED_USB;
+ }
+
+ private boolean hasWifiConnection() {
+ final ConnectivityManager manager =
+ (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+ final NetworkInfo wifiInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+ return wifiInfo.isConnected();
+ }
+
+ private void doUpload(final boolean isUploadingUnconditionally) {
+ if (!isUploadingUnconditionally && (!isExternallyPowered() || !hasWifiConnection())) {
+ return;
+ }
+ if (mFilesDir == null) {
+ return;
+ }
+ final File[] files = mFilesDir.listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File pathname) {
+ return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX)
+ && !pathname.canWrite();
+ }
+ });
+ boolean success = true;
+ if (files.length == 0) {
+ success = false;
+ }
+ for (final File file : files) {
+ if (!uploadFile(file)) {
+ success = false;
+ }
+ }
+ }
+
+ private boolean uploadFile(File file) {
+ Log.d(TAG, "attempting upload of " + file.getAbsolutePath());
+ boolean success = false;
+ final int contentLength = (int) file.length();
+ HttpURLConnection connection = null;
+ InputStream fileInputStream = null;
+ try {
+ fileInputStream = new FileInputStream(file);
+ connection = (HttpURLConnection) mUrl.openConnection();
+ connection.setRequestMethod("PUT");
+ connection.setDoOutput(true);
+ connection.setFixedLengthStreamingMode(contentLength);
+ final OutputStream os = connection.getOutputStream();
+ final byte[] buf = new byte[BUF_SIZE];
+ int numBytesRead;
+ while ((numBytesRead = fileInputStream.read(buf)) != -1) {
+ os.write(buf, 0, numBytesRead);
+ }
+ if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+ Log.d(TAG, "upload failed: " + connection.getResponseCode());
+ InputStream netInputStream = connection.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(netInputStream));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ Log.d(TAG, "| " + reader.readLine());
+ }
+ reader.close();
+ return success;
+ }
+ file.delete();
+ success = true;
+ Log.d(TAG, "upload successful");
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ if (fileInputStream != null) {
+ try {
+ fileInputStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ return success;
+ }
+}