ResearchLogger.java revision 9c3860ce461c3791891bf667edc77fe798c8d332
1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.inputmethod.research;
18
19import static com.android.inputmethod.latin.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET;
20
21import android.accounts.Account;
22import android.accounts.AccountManager;
23import android.app.AlarmManager;
24import android.app.AlertDialog;
25import android.app.Dialog;
26import android.app.PendingIntent;
27import android.content.Context;
28import android.content.DialogInterface;
29import android.content.DialogInterface.OnCancelListener;
30import android.content.Intent;
31import android.content.SharedPreferences;
32import android.content.SharedPreferences.Editor;
33import android.content.pm.PackageInfo;
34import android.content.pm.PackageManager.NameNotFoundException;
35import android.content.res.Resources;
36import android.graphics.Canvas;
37import android.graphics.Color;
38import android.graphics.Paint;
39import android.graphics.Paint.Style;
40import android.net.Uri;
41import android.os.Build;
42import android.os.Bundle;
43import android.os.Handler;
44import android.os.IBinder;
45import android.os.SystemClock;
46import android.preference.PreferenceManager;
47import android.text.TextUtils;
48import android.text.format.DateUtils;
49import android.util.Log;
50import android.view.KeyEvent;
51import android.view.MotionEvent;
52import android.view.Window;
53import android.view.WindowManager;
54import android.view.inputmethod.CompletionInfo;
55import android.view.inputmethod.EditorInfo;
56import android.view.inputmethod.InputConnection;
57import android.widget.Toast;
58
59import com.android.inputmethod.keyboard.Key;
60import com.android.inputmethod.keyboard.Keyboard;
61import com.android.inputmethod.keyboard.KeyboardId;
62import com.android.inputmethod.keyboard.KeyboardSwitcher;
63import com.android.inputmethod.keyboard.KeyboardView;
64import com.android.inputmethod.keyboard.MainKeyboardView;
65import com.android.inputmethod.latin.Constants;
66import com.android.inputmethod.latin.Dictionary;
67import com.android.inputmethod.latin.InputTypeUtils;
68import com.android.inputmethod.latin.LatinIME;
69import com.android.inputmethod.latin.R;
70import com.android.inputmethod.latin.RichInputConnection;
71import com.android.inputmethod.latin.RichInputConnection.Range;
72import com.android.inputmethod.latin.Suggest;
73import com.android.inputmethod.latin.SuggestedWords;
74import com.android.inputmethod.latin.define.ProductionFlag;
75import com.android.inputmethod.research.MotionEventReader.ReplayData;
76
77import java.io.BufferedReader;
78import java.io.File;
79import java.io.FileInputStream;
80import java.io.FileNotFoundException;
81import java.io.IOException;
82import java.io.InputStreamReader;
83import java.nio.MappedByteBuffer;
84import java.nio.channels.FileChannel;
85import java.nio.charset.Charset;
86import java.text.SimpleDateFormat;
87import java.util.ArrayList;
88import java.util.Date;
89import java.util.List;
90import java.util.Locale;
91import java.util.Random;
92import java.util.UUID;
93
94/**
95 * Logs the use of the LatinIME keyboard.
96 *
97 * This class logs operations on the IME keyboard, including what the user has typed.
98 * Data is stored locally in a file in app-specific storage.
99 *
100 * This functionality is off by default. See
101 * {@link ProductionFlag#USES_DEVELOPMENT_ONLY_DIAGNOSTICS}.
102 */
103public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChangeListener {
104    // TODO: This class has grown quite large and combines several concerns that should be
105    // separated.  The following refactorings will be applied as soon as possible after adding
106    // support for replaying historical events, fixing some replay bugs, adding some ui constraints
107    // on the feedback dialog, and adding the survey dialog.
108    // TODO: Refactor.  Move splash screen code into separate class.
109    // TODO: Refactor.  Move feedback screen code into separate class.
110    // TODO: Refactor.  Move logging invocations into their own class.
111    // TODO: Refactor.  Move currentLogUnit management into separate class.
112    private static final String TAG = ResearchLogger.class.getSimpleName();
113    private static final boolean DEBUG = false
114            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
115    private static final boolean DEBUG_REPLAY_AFTER_FEEDBACK = false
116            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
117    // Whether the TextView contents are logged at the end of the session.  true will disclose
118    // private info.
119    private static final boolean LOG_FULL_TEXTVIEW_CONTENTS = false
120            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
121    // Whether the feedback dialog preserves the editable text across invocations.  Should be false
122    // for normal research builds so users do not have to delete the same feedback string they
123    // entered earlier.  Should be true for builds internal to a development team so when the text
124    // field holds a channel name, the developer does not have to re-enter it when using the
125    // feedback mechanism to generate multiple tests.
126    private static final boolean FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD = false;
127    public static final boolean DEFAULT_USABILITY_STUDY_MODE = false;
128    /* package */ static boolean sIsLogging = false;
129    private static final int OUTPUT_FORMAT_VERSION = 5;
130    private static final String PREF_USABILITY_STUDY_MODE = "usability_study_mode";
131    private static final String PREF_RESEARCH_HAS_SEEN_SPLASH = "pref_research_has_seen_splash";
132    /* package */ static final String LOG_FILENAME_PREFIX = "researchLog";
133    private static final String LOG_FILENAME_SUFFIX = ".txt";
134    /* package */ static final String USER_RECORDING_FILENAME_PREFIX = "recording";
135    private static final String USER_RECORDING_FILENAME_SUFFIX = ".txt";
136    private static final SimpleDateFormat TIMESTAMP_DATEFORMAT =
137            new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US);
138    // Whether all words should be recorded, leaving unsampled word between bigrams.  Useful for
139    // testing.
140    /* package for test */ static final boolean IS_LOGGING_EVERYTHING = false
141            && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG;
142    // The number of words between n-grams to omit from the log.
143    private static final int NUMBER_OF_WORDS_BETWEEN_SAMPLES =
144            IS_LOGGING_EVERYTHING ? 0 : (DEBUG ? 2 : 18);
145
146    // Whether to show an indicator on the screen that logging is on.  Currently a very small red
147    // dot in the lower right hand corner.  Most users should not notice it.
148    private static final boolean IS_SHOWING_INDICATOR = true;
149    // Change the default indicator to something very visible.  Currently two red vertical bars on
150    // either side of they keyboard.
151    private static final boolean IS_SHOWING_INDICATOR_CLEARLY = false ||
152            (IS_LOGGING_EVERYTHING && ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG);
153    // FEEDBACK_WORD_BUFFER_SIZE should add 1 because it must also hold the feedback LogUnit itself.
154    public static final int FEEDBACK_WORD_BUFFER_SIZE = (Integer.MAX_VALUE - 1) + 1;
155
156    // constants related to specific log points
157    private static final String WHITESPACE_SEPARATORS = " \t\n\r";
158    private static final int MAX_INPUTVIEW_LENGTH_TO_CAPTURE = 8192; // must be >=1
159    private static final String PREF_RESEARCH_LOGGER_UUID_STRING = "pref_research_logger_uuid";
160    private static final String PREF_RESEARCH_SAVED_CHANNEL = "pref_research_saved_channel";
161
162    private static final ResearchLogger sInstance = new ResearchLogger();
163    private static String sAccountType = null;
164    private static String sAllowedAccountDomain = null;
165    // to write to a different filename, e.g., for testing, set mFile before calling start()
166    /* package */ File mFilesDir;
167    /* package */ String mUUIDString;
168    /* package */ ResearchLog mMainResearchLog;
169    // mFeedbackLog records all events for the session, private or not (excepting
170    // passwords).  It is written to permanent storage only if the user explicitly commands
171    // the system to do so.
172    // LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are
173    // complete.
174    /* package */ MainLogBuffer mMainLogBuffer;
175    // TODO: Remove the feedback log.  The feedback log continuously captured user data in case the
176    // user wanted to submit it.  We now use the mUserRecordingLogBuffer to allow the user to
177    // explicitly reproduce a problem.
178    /* package */ ResearchLog mFeedbackLog;
179    /* package */ LogBuffer mFeedbackLogBuffer;
180    /* package */ ResearchLog mUserRecordingLog;
181    /* package */ LogBuffer mUserRecordingLogBuffer;
182    private File mUserRecordingFile = null;
183
184    private boolean mIsPasswordView = false;
185    private boolean mIsLoggingSuspended = false;
186    private SharedPreferences mPrefs;
187
188    // digits entered by the user are replaced with this codepoint.
189    /* package for test */ static final int DIGIT_REPLACEMENT_CODEPOINT =
190            Character.codePointAt("\uE000", 0);  // U+E000 is in the "private-use area"
191    // U+E001 is in the "private-use area"
192    /* package for test */ static final String WORD_REPLACEMENT_STRING = "\uE001";
193    private static final String PREF_LAST_CLEANUP_TIME = "pref_last_cleanup_time";
194    private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS;
195    private static final long MAX_LOGFILE_AGE_IN_MS = 4 * DateUtils.DAY_IN_MILLIS;
196    protected static final int SUSPEND_DURATION_IN_MINUTES = 1;
197    // set when LatinIME should ignore an onUpdateSelection() callback that
198    // arises from operations in this class
199    private static boolean sLatinIMEExpectingUpdateSelection = false;
200
201    // used to check whether words are not unique
202    private Suggest mSuggest;
203    private MainKeyboardView mMainKeyboardView;
204    // TODO: Check whether a superclass can be used instead of LatinIME.
205    /* package for test */ LatinIME mLatinIME;
206    private final Statistics mStatistics;
207    private final MotionEventReader mMotionEventReader = new MotionEventReader();
208    private final Replayer mReplayer = Replayer.getInstance();
209
210    private Intent mUploadIntent;
211    private Intent mUploadNowIntent;
212
213    private LogUnit mCurrentLogUnit = new LogUnit();
214
215    // Gestured or tapped words may be committed after the gesture of the next word has started.
216    // To ensure that the gesture data of the next word is not associated with the previous word,
217    // thereby leaking private data, we store the time of the down event that started the second
218    // gesture, and when committing the earlier word, split the LogUnit.
219    private long mSavedDownEventTime;
220    private Bundle mFeedbackDialogBundle = null;
221    private boolean mInFeedbackDialog = false;
222    // The feedback dialog causes stop() to be called for the keyboard connected to the original
223    // window.  This is because the feedback dialog must present its own EditText box that displays
224    // a keyboard.  stop() normally causes mFeedbackLogBuffer, which contains the user's data, to be
225    // cleared, and causes mFeedbackLog, which is ready to collect information in case the user
226    // wants to upload, to be closed.  This is good because we don't need to log information about
227    // what the user is typing in the feedback dialog, but bad because this data must be uploaded.
228    // Here we save the LogBuffer and Log so the feedback dialog can later access their data.
229    private LogBuffer mSavedFeedbackLogBuffer;
230    private ResearchLog mSavedFeedbackLog;
231    private Handler mUserRecordingTimeoutHandler;
232    private static final long USER_RECORDING_TIMEOUT_MS = 30L * DateUtils.SECOND_IN_MILLIS;
233
234    private ResearchLogger() {
235        mStatistics = Statistics.getInstance();
236    }
237
238    public static ResearchLogger getInstance() {
239        return sInstance;
240    }
241
242    public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) {
243        assert latinIME != null;
244        if (latinIME == null) {
245            Log.w(TAG, "IMS is null; logging is off");
246        } else {
247            mFilesDir = latinIME.getFilesDir();
248            if (mFilesDir == null || !mFilesDir.exists()) {
249                Log.w(TAG, "IME storage directory does not exist.");
250            }
251        }
252        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(latinIME);
253        if (prefs != null) {
254            mUUIDString = getUUID(prefs);
255            if (!prefs.contains(PREF_USABILITY_STUDY_MODE)) {
256                Editor e = prefs.edit();
257                e.putBoolean(PREF_USABILITY_STUDY_MODE, DEFAULT_USABILITY_STUDY_MODE);
258                e.apply();
259            }
260            sIsLogging = prefs.getBoolean(PREF_USABILITY_STUDY_MODE, false);
261            prefs.registerOnSharedPreferenceChangeListener(this);
262
263            final long lastCleanupTime = prefs.getLong(PREF_LAST_CLEANUP_TIME, 0L);
264            final long now = System.currentTimeMillis();
265            if (lastCleanupTime + DURATION_BETWEEN_DIR_CLEANUP_IN_MS < now) {
266                final long timeHorizon = now - MAX_LOGFILE_AGE_IN_MS;
267                cleanupLoggingDir(mFilesDir, timeHorizon);
268                Editor e = prefs.edit();
269                e.putLong(PREF_LAST_CLEANUP_TIME, now);
270                e.apply();
271            }
272        }
273        final Resources res = latinIME.getResources();
274        sAccountType = res.getString(R.string.research_account_type);
275        sAllowedAccountDomain = res.getString(R.string.research_allowed_account_domain);
276        mLatinIME = latinIME;
277        mPrefs = prefs;
278        mUploadIntent = new Intent(mLatinIME, UploaderService.class);
279        mUploadNowIntent = new Intent(mLatinIME, UploaderService.class);
280        mUploadNowIntent.putExtra(UploaderService.EXTRA_UPLOAD_UNCONDITIONALLY, true);
281        mReplayer.setKeyboardSwitcher(keyboardSwitcher);
282
283        if (ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS) {
284            scheduleUploadingService(mLatinIME);
285        }
286    }
287
288    /**
289     * Arrange for the UploaderService to be run on a regular basis.
290     *
291     * Any existing scheduled invocation of UploaderService is removed and rescheduled.  This may
292     * cause problems if this method is called often and frequent updates are required, but since
293     * the user will likely be sleeping at some point, if the interval is less that the expected
294     * sleep duration and this method is not called during that time, the service should be invoked
295     * at some point.
296     */
297    public static void scheduleUploadingService(Context context) {
298        final Intent intent = new Intent(context, UploaderService.class);
299        final PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, 0);
300        final AlarmManager manager =
301                (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
302        manager.cancel(pendingIntent);
303        manager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
304                UploaderService.RUN_INTERVAL, UploaderService.RUN_INTERVAL, pendingIntent);
305    }
306
307    private void cleanupLoggingDir(final File dir, final long time) {
308        for (File file : dir.listFiles()) {
309            final String filename = file.getName();
310            if ((filename.startsWith(ResearchLogger.LOG_FILENAME_PREFIX)
311                    || filename.startsWith(ResearchLogger.USER_RECORDING_FILENAME_PREFIX))
312                    && file.lastModified() < time) {
313                file.delete();
314            }
315        }
316    }
317
318    public void mainKeyboardView_onAttachedToWindow(final MainKeyboardView mainKeyboardView) {
319        mMainKeyboardView = mainKeyboardView;
320        maybeShowSplashScreen();
321    }
322
323    public void mainKeyboardView_onDetachedFromWindow() {
324        mMainKeyboardView = null;
325    }
326
327    private boolean hasSeenSplash() {
328        return mPrefs.getBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, false);
329    }
330
331    private Dialog mSplashDialog = null;
332
333    private void maybeShowSplashScreen() {
334        if (hasSeenSplash()) {
335            return;
336        }
337        if (mSplashDialog != null && mSplashDialog.isShowing()) {
338            return;
339        }
340        final IBinder windowToken = mMainKeyboardView != null
341                ? mMainKeyboardView.getWindowToken() : null;
342        if (windowToken == null) {
343            return;
344        }
345        final AlertDialog.Builder builder = new AlertDialog.Builder(mLatinIME)
346                .setTitle(R.string.research_splash_title)
347                .setMessage(R.string.research_splash_content)
348                .setPositiveButton(android.R.string.yes,
349                        new DialogInterface.OnClickListener() {
350                            @Override
351                            public void onClick(DialogInterface dialog, int which) {
352                                onUserLoggingConsent();
353                                mSplashDialog.dismiss();
354                            }
355                })
356                .setNegativeButton(android.R.string.no,
357                        new DialogInterface.OnClickListener() {
358                            @Override
359                            public void onClick(DialogInterface dialog, int which) {
360                                final String packageName = mLatinIME.getPackageName();
361                                final Uri packageUri = Uri.parse("package:" + packageName);
362                                final Intent intent = new Intent(Intent.ACTION_UNINSTALL_PACKAGE,
363                                        packageUri);
364                                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
365                                mLatinIME.startActivity(intent);
366                            }
367                })
368                .setCancelable(true)
369                .setOnCancelListener(
370                        new OnCancelListener() {
371                            @Override
372                            public void onCancel(DialogInterface dialog) {
373                                mLatinIME.requestHideSelf(0);
374                            }
375                });
376        mSplashDialog = builder.create();
377        final Window w = mSplashDialog.getWindow();
378        final WindowManager.LayoutParams lp = w.getAttributes();
379        lp.token = windowToken;
380        lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG;
381        w.setAttributes(lp);
382        w.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
383        mSplashDialog.show();
384    }
385
386    public void onUserLoggingConsent() {
387        setLoggingAllowed(true);
388        if (mPrefs == null) {
389            return;
390        }
391        final Editor e = mPrefs.edit();
392        e.putBoolean(PREF_RESEARCH_HAS_SEEN_SPLASH, true);
393        e.apply();
394        restart();
395    }
396
397    private void setLoggingAllowed(boolean enableLogging) {
398        if (mPrefs == null) {
399            return;
400        }
401        Editor e = mPrefs.edit();
402        e.putBoolean(PREF_USABILITY_STUDY_MODE, enableLogging);
403        e.apply();
404        sIsLogging = enableLogging;
405    }
406
407    private static int sLogFileCounter = 0;
408
409    private File createLogFile(final File filesDir) {
410        final StringBuilder sb = new StringBuilder();
411        sb.append(LOG_FILENAME_PREFIX).append('-');
412        sb.append(mUUIDString).append('-');
413        sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-');
414        // Sometimes logFiles are created within milliseconds of each other.  Append a counter to
415        // separate these.
416        if (sLogFileCounter < Integer.MAX_VALUE) {
417            sLogFileCounter++;
418        } else {
419            // Wrap the counter, in the unlikely event of overflow.
420            sLogFileCounter = 0;
421        }
422        sb.append(sLogFileCounter);
423        sb.append(LOG_FILENAME_SUFFIX);
424        return new File(filesDir, sb.toString());
425    }
426
427    private File createUserRecordingFile(final File filesDir) {
428        final StringBuilder sb = new StringBuilder();
429        sb.append(USER_RECORDING_FILENAME_PREFIX).append('-');
430        sb.append(mUUIDString).append('-');
431        sb.append(TIMESTAMP_DATEFORMAT.format(new Date()));
432        sb.append(USER_RECORDING_FILENAME_SUFFIX);
433        return new File(filesDir, sb.toString());
434    }
435
436    private void checkForEmptyEditor() {
437        if (mLatinIME == null) {
438            return;
439        }
440        final InputConnection ic = mLatinIME.getCurrentInputConnection();
441        if (ic == null) {
442            return;
443        }
444        final CharSequence textBefore = ic.getTextBeforeCursor(1, 0);
445        if (!TextUtils.isEmpty(textBefore)) {
446            mStatistics.setIsEmptyUponStarting(false);
447            return;
448        }
449        final CharSequence textAfter = ic.getTextAfterCursor(1, 0);
450        if (!TextUtils.isEmpty(textAfter)) {
451            mStatistics.setIsEmptyUponStarting(false);
452            return;
453        }
454        if (textBefore != null && textAfter != null) {
455            mStatistics.setIsEmptyUponStarting(true);
456        }
457    }
458
459    private void start() {
460        if (DEBUG) {
461            Log.d(TAG, "start called");
462        }
463        maybeShowSplashScreen();
464        updateSuspendedState();
465        requestIndicatorRedraw();
466        mStatistics.reset();
467        checkForEmptyEditor();
468        if (!isAllowedToLog()) {
469            // Log.w(TAG, "not in usability mode; not logging");
470            return;
471        }
472        if (mFilesDir == null || !mFilesDir.exists()) {
473            Log.w(TAG, "IME storage directory does not exist.  Cannot start logging.");
474            return;
475        }
476        if (mMainLogBuffer == null) {
477            mMainResearchLog = new ResearchLog(createLogFile(mFilesDir), mLatinIME);
478            final int numWordsToIgnore = new Random().nextInt(NUMBER_OF_WORDS_BETWEEN_SAMPLES + 1);
479            mMainLogBuffer = new MainLogBuffer(NUMBER_OF_WORDS_BETWEEN_SAMPLES, numWordsToIgnore) {
480                @Override
481                protected void publish(final ArrayList<LogUnit> logUnits,
482                        boolean canIncludePrivateData) {
483                    canIncludePrivateData |= IS_LOGGING_EVERYTHING;
484                    final int length = logUnits.size();
485                    for (int i = 0; i < length; i++) {
486                        final LogUnit logUnit = logUnits.get(i);
487                        final String word = logUnit.getWord();
488                        if (word != null && word.length() > 0 && hasLetters(word)) {
489                            Log.d(TAG, "onPublish: " + word + ", hc: "
490                                    + logUnit.containsCorrection());
491                            final Dictionary dictionary = getDictionary();
492                            mStatistics.recordWordEntered(
493                                    dictionary != null && dictionary.isValidWord(word),
494                                    logUnit.containsCorrection());
495                        }
496                    }
497                    if (mMainResearchLog != null) {
498                        publishLogUnits(logUnits, mMainResearchLog, canIncludePrivateData);
499                    }
500                }
501            };
502            mMainLogBuffer.setSuggest(mSuggest);
503        }
504        if (mFeedbackLogBuffer == null) {
505            resetFeedbackLogging();
506        }
507    }
508
509    private void resetFeedbackLogging() {
510        mFeedbackLog = new ResearchLog(createLogFile(mFilesDir), mLatinIME);
511        mFeedbackLogBuffer = new FixedLogBuffer(FEEDBACK_WORD_BUFFER_SIZE);
512    }
513
514    /* package */ void stop() {
515        if (DEBUG) {
516            Log.d(TAG, "stop called");
517        }
518        // Commit mCurrentLogUnit before closing.
519        commitCurrentLogUnit();
520
521        if (mMainLogBuffer != null) {
522            mMainLogBuffer.shiftAndPublishAll();
523            logStatistics();
524            commitCurrentLogUnit();
525            mMainLogBuffer.setIsStopping();
526            mMainLogBuffer.shiftAndPublishAll();
527            mMainResearchLog.close(null /* callback */);
528            mMainLogBuffer = null;
529        }
530        if (mFeedbackLogBuffer != null) {
531            mFeedbackLog.close(null /* callback */);
532            mFeedbackLogBuffer = null;
533        }
534    }
535
536    public boolean abort() {
537        if (DEBUG) {
538            Log.d(TAG, "abort called");
539        }
540        boolean didAbortMainLog = false;
541        if (mMainLogBuffer != null) {
542            mMainLogBuffer.clear();
543            try {
544                didAbortMainLog = mMainResearchLog.blockingAbort();
545            } catch (InterruptedException e) {
546                // Don't know whether this succeeded or not.  We assume not; this is reported
547                // to the caller.
548            }
549            mMainLogBuffer = null;
550        }
551        boolean didAbortFeedbackLog = false;
552        if (mFeedbackLogBuffer != null) {
553            mFeedbackLogBuffer.clear();
554            try {
555                didAbortFeedbackLog = mFeedbackLog.blockingAbort();
556            } catch (InterruptedException e) {
557                // Don't know whether this succeeded or not.  We assume not; this is reported
558                // to the caller.
559            }
560            mFeedbackLogBuffer = null;
561        }
562        return didAbortMainLog && didAbortFeedbackLog;
563    }
564
565    private void restart() {
566        stop();
567        start();
568    }
569
570    private long mResumeTime = 0L;
571    private void updateSuspendedState() {
572        final long time = System.currentTimeMillis();
573        if (time > mResumeTime) {
574            mIsLoggingSuspended = false;
575        }
576    }
577
578    @Override
579    public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
580        if (key == null || prefs == null) {
581            return;
582        }
583        sIsLogging = prefs.getBoolean(PREF_USABILITY_STUDY_MODE, false);
584        if (sIsLogging == false) {
585            abort();
586        }
587        requestIndicatorRedraw();
588        mPrefs = prefs;
589        prefsChanged(prefs);
590    }
591
592    public void onResearchKeySelected(final LatinIME latinIME) {
593        if (mInFeedbackDialog) {
594            Toast.makeText(latinIME, R.string.research_please_exit_feedback_form,
595                    Toast.LENGTH_LONG).show();
596            return;
597        }
598        presentFeedbackDialog(latinIME);
599    }
600
601    public void presentFeedbackDialog(LatinIME latinIME) {
602        if (isMakingUserRecording()) {
603            saveRecording();
604        }
605        mInFeedbackDialog = true;
606        mSavedFeedbackLogBuffer = mFeedbackLogBuffer;
607        mSavedFeedbackLog = mFeedbackLog;
608        // Set the non-saved versions to null so that the stop() caused by switching to the
609        // Feedback dialog will not close them.
610        mFeedbackLogBuffer = null;
611        mFeedbackLog = null;
612
613        final Intent intent = new Intent();
614        intent.setClass(mLatinIME, FeedbackActivity.class);
615        if (mFeedbackDialogBundle == null) {
616            // Restore feedback field with channel name
617            final Bundle bundle = new Bundle();
618            bundle.putBoolean(FeedbackFragment.KEY_INCLUDE_ACCOUNT_NAME, true);
619            bundle.putBoolean(FeedbackFragment.KEY_HAS_USER_RECORDING, false);
620            if (FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD) {
621                final String savedChannelName = mPrefs.getString(PREF_RESEARCH_SAVED_CHANNEL, "");
622                bundle.putString(FeedbackFragment.KEY_FEEDBACK_STRING, savedChannelName);
623            }
624            mFeedbackDialogBundle = bundle;
625        }
626        intent.putExtras(mFeedbackDialogBundle);
627        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
628        latinIME.startActivity(intent);
629    }
630
631    public void setFeedbackDialogBundle(final Bundle bundle) {
632        mFeedbackDialogBundle = bundle;
633    }
634
635    public void startRecording() {
636        final Resources res = mLatinIME.getResources();
637        Toast.makeText(mLatinIME,
638                res.getString(R.string.research_feedback_demonstration_instructions),
639                Toast.LENGTH_LONG).show();
640        startRecordingInternal();
641    }
642
643    private void startRecordingInternal() {
644        if (mUserRecordingLog != null) {
645            mUserRecordingLog.abort();
646        }
647        mUserRecordingFile = createUserRecordingFile(mFilesDir);
648        mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME);
649        mUserRecordingLogBuffer = new LogBuffer();
650        resetRecordingTimer();
651    }
652
653    private boolean isMakingUserRecording() {
654        return mUserRecordingLog != null;
655    }
656
657    private void resetRecordingTimer() {
658        if (mUserRecordingTimeoutHandler == null) {
659            mUserRecordingTimeoutHandler = new Handler();
660        }
661        clearRecordingTimer();
662        mUserRecordingTimeoutHandler.postDelayed(mRecordingHandlerTimeoutRunnable,
663                USER_RECORDING_TIMEOUT_MS);
664    }
665
666    private void clearRecordingTimer() {
667        mUserRecordingTimeoutHandler.removeCallbacks(mRecordingHandlerTimeoutRunnable);
668    }
669
670    private Runnable mRecordingHandlerTimeoutRunnable = new Runnable() {
671        @Override
672        public void run() {
673            cancelRecording();
674            requestIndicatorRedraw();
675            final Resources res = mLatinIME.getResources();
676            Toast.makeText(mLatinIME, res.getString(R.string.research_feedback_recording_failure),
677                    Toast.LENGTH_LONG).show();
678        }
679    };
680
681    private void cancelRecording() {
682        if (mUserRecordingLog != null) {
683            mUserRecordingLog.abort();
684        }
685        mUserRecordingLog = null;
686        mUserRecordingLogBuffer = null;
687        if (mFeedbackDialogBundle != null) {
688            mFeedbackDialogBundle.putBoolean("HasRecording", false);
689        }
690    }
691
692    private void saveRecording() {
693        commitCurrentLogUnit();
694        publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true);
695        mUserRecordingLog.close(null);
696        mUserRecordingLog = null;
697        mUserRecordingLogBuffer = null;
698
699        if (mFeedbackDialogBundle != null) {
700            mFeedbackDialogBundle.putBoolean(FeedbackFragment.KEY_HAS_USER_RECORDING, true);
701        }
702        clearRecordingTimer();
703    }
704
705    // TODO: currently unreachable.  Remove after being sure enable/disable is
706    // not needed.
707    /*
708    public void enableOrDisable(final boolean showEnable, final LatinIME latinIME) {
709        if (showEnable) {
710            if (!sIsLogging) {
711                setLoggingAllowed(true);
712            }
713            resumeLogging();
714            Toast.makeText(latinIME,
715                    R.string.research_notify_session_logging_enabled,
716                    Toast.LENGTH_LONG).show();
717        } else {
718            Toast toast = Toast.makeText(latinIME,
719                    R.string.research_notify_session_log_deleting,
720                    Toast.LENGTH_LONG);
721            toast.show();
722            boolean isLogDeleted = abort();
723            final long currentTime = System.currentTimeMillis();
724            final long resumeTime = currentTime + 1000 * 60 *
725                    SUSPEND_DURATION_IN_MINUTES;
726            suspendLoggingUntil(resumeTime);
727            toast.cancel();
728            Toast.makeText(latinIME, R.string.research_notify_logging_suspended,
729                    Toast.LENGTH_LONG).show();
730        }
731    }
732    */
733
734    /**
735     * Get the name of the first allowed account on the device.
736     *
737     * Allowed accounts must be in the domain given by ALLOWED_ACCOUNT_DOMAIN.
738     *
739     * @return The user's account name.
740     */
741    public String getAccountName() {
742        if (sAccountType == null || sAccountType.isEmpty()) {
743            return null;
744        }
745        if (sAllowedAccountDomain == null || sAllowedAccountDomain.isEmpty()) {
746            return null;
747        }
748        final AccountManager manager = AccountManager.get(mLatinIME);
749        // Filter first by account type.
750        final Account[] accounts = manager.getAccountsByType(sAccountType);
751
752        for (final Account account : accounts) {
753            if (DEBUG) {
754                Log.d(TAG, account.name);
755            }
756            final String[] parts = account.name.split("@");
757            if (parts.length > 1 && parts[1].equals(sAllowedAccountDomain)) {
758                return parts[0];
759            }
760        }
761        return null;
762    }
763
764    private static final LogStatement LOGSTATEMENT_FEEDBACK =
765            new LogStatement("UserFeedback", false, false, "contents", "accountName", "recording");
766    public void sendFeedback(final String feedbackContents, final boolean includeHistory,
767            final boolean isIncludingAccountName, final boolean isIncludingRecording) {
768        if (mSavedFeedbackLogBuffer == null) {
769            return;
770        }
771        if (!includeHistory) {
772            mSavedFeedbackLogBuffer.clear();
773        }
774        String recording = "";
775        if (isIncludingRecording) {
776            // Try to read recording from recently written json file
777            if (mUserRecordingFile != null) {
778                FileChannel channel = null;
779                try {
780                    channel = new FileInputStream(mUserRecordingFile).getChannel();
781                    final MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0,
782                            channel.size());
783                    // Android's openFileOutput() creates the file, so we use Android's default
784                    // Charset (UTF-8) here to read it.
785                    recording = Charset.defaultCharset().decode(buffer).toString();
786                } catch (FileNotFoundException e) {
787                    Log.e(TAG, "Could not find recording file", e);
788                } catch (IOException e) {
789                    Log.e(TAG, "Error reading recording file", e);
790                } finally {
791                    if (channel != null) {
792                        try {
793                            channel.close();
794                        } catch (IOException e) {
795                            Log.e(TAG, "Error closing recording file", e);
796                        }
797                    }
798                }
799            }
800        }
801        final LogUnit feedbackLogUnit = new LogUnit();
802        final String accountName = isIncludingAccountName ? getAccountName() : "";
803        feedbackLogUnit.addLogStatement(LOGSTATEMENT_FEEDBACK, SystemClock.uptimeMillis(),
804                feedbackContents, accountName, recording);
805        mFeedbackLogBuffer.shiftIn(feedbackLogUnit);
806        publishLogBuffer(mFeedbackLogBuffer, mSavedFeedbackLog, true /* isIncludingPrivateData */);
807        mSavedFeedbackLog.close(new Runnable() {
808            @Override
809            public void run() {
810                uploadNow();
811            }
812        });
813
814        if (isIncludingRecording && DEBUG_REPLAY_AFTER_FEEDBACK) {
815            final Handler handler = new Handler();
816            handler.postDelayed(new Runnable() {
817                @Override
818                public void run() {
819                    final ReplayData replayData =
820                            mMotionEventReader.readMotionEventData(mUserRecordingFile);
821                    mReplayer.replay(replayData, null);
822                }
823            }, 1000);
824        }
825
826        if (FEEDBACK_DIALOG_SHOULD_PRESERVE_TEXT_FIELD) {
827            // Use feedback string as a channel name to label feedback strings.  Here we record the
828            // string for prepopulating the field next time.
829            final String channelName = feedbackContents;
830            if (mPrefs == null) {
831                return;
832            }
833            final Editor e = mPrefs.edit();
834            e.putString(PREF_RESEARCH_SAVED_CHANNEL, channelName);
835            e.apply();
836        }
837    }
838
839    public void uploadNow() {
840        if (DEBUG) {
841            Log.d(TAG, "calling uploadNow()");
842        }
843        mLatinIME.startService(mUploadNowIntent);
844    }
845
846    public void onLeavingSendFeedbackDialog() {
847        mInFeedbackDialog = false;
848    }
849
850    public void initSuggest(Suggest suggest) {
851        mSuggest = suggest;
852        if (mMainLogBuffer != null) {
853            mMainLogBuffer.setSuggest(mSuggest);
854        }
855    }
856
857    private Dictionary getDictionary() {
858        if (mSuggest == null) {
859            return null;
860        }
861        return mSuggest.getMainDictionary();
862    }
863
864    private void setIsPasswordView(boolean isPasswordView) {
865        mIsPasswordView = isPasswordView;
866    }
867
868    private boolean isAllowedToLog() {
869        return !mIsPasswordView && !mIsLoggingSuspended && sIsLogging && !mInFeedbackDialog
870                && !isReplaying();
871    }
872
873    public void requestIndicatorRedraw() {
874        if (!IS_SHOWING_INDICATOR) {
875            return;
876        }
877        if (mMainKeyboardView == null) {
878            return;
879        }
880        mMainKeyboardView.invalidateAllKeys();
881    }
882
883    private boolean isReplaying() {
884        return mReplayer.isReplaying();
885    }
886
887    private int getIndicatorColor() {
888        if (isMakingUserRecording()) {
889            return Color.YELLOW;
890        }
891        if (isReplaying()) {
892            return Color.GREEN;
893        }
894        return Color.RED;
895    }
896
897    public void paintIndicator(KeyboardView view, Paint paint, Canvas canvas, int width,
898            int height) {
899        // TODO: Reimplement using a keyboard background image specific to the ResearchLogger
900        // and remove this method.
901        // The check for MainKeyboardView ensures that the indicator only decorates the main
902        // keyboard, not every keyboard.
903        if (IS_SHOWING_INDICATOR && (isAllowedToLog() || isReplaying())
904                && view instanceof MainKeyboardView) {
905            final int savedColor = paint.getColor();
906            paint.setColor(getIndicatorColor());
907            final Style savedStyle = paint.getStyle();
908            paint.setStyle(Style.STROKE);
909            final float savedStrokeWidth = paint.getStrokeWidth();
910            if (IS_SHOWING_INDICATOR_CLEARLY) {
911                paint.setStrokeWidth(5);
912                canvas.drawLine(0, 0, 0, height, paint);
913                canvas.drawLine(width, 0, width, height, paint);
914            } else {
915                // Put a tiny dot on the screen so a knowledgeable user can check whether it is
916                // enabled.  The dot is actually a zero-width, zero-height rectangle, placed at the
917                // lower-right corner of the canvas, painted with a non-zero border width.
918                paint.setStrokeWidth(3);
919                canvas.drawRect(width, height, width, height, paint);
920            }
921            paint.setColor(savedColor);
922            paint.setStyle(savedStyle);
923            paint.setStrokeWidth(savedStrokeWidth);
924        }
925    }
926
927    /**
928     * Buffer a research log event, flagging it as privacy-sensitive.
929     */
930    private synchronized void enqueueEvent(final LogStatement logStatement,
931            final Object... values) {
932        enqueueEvent(mCurrentLogUnit, logStatement, values);
933    }
934
935    private synchronized void enqueueEvent(final LogUnit logUnit, final LogStatement logStatement,
936            final Object... values) {
937        assert values.length == logStatement.getKeys().length;
938        if (isAllowedToLog() && logUnit != null) {
939            final long time = SystemClock.uptimeMillis();
940            logUnit.addLogStatement(logStatement, time, values);
941        }
942    }
943
944    private void setCurrentLogUnitContainsDigitFlag() {
945        mCurrentLogUnit.setMayContainDigit();
946    }
947
948    private void setCurrentLogUnitContainsCorrection() {
949        mCurrentLogUnit.setContainsCorrection();
950    }
951
952    private void setCurrentLogUnitCorrectionType(final int correctionType) {
953        mCurrentLogUnit.setCorrectionType(correctionType);
954    }
955
956    /* package for test */ void commitCurrentLogUnit() {
957        if (DEBUG) {
958            Log.d(TAG, "commitCurrentLogUnit" + (mCurrentLogUnit.hasWord() ?
959                    ": " + mCurrentLogUnit.getWord() : ""));
960        }
961        if (!mCurrentLogUnit.isEmpty()) {
962            if (mMainLogBuffer != null) {
963                mMainLogBuffer.shiftIn(mCurrentLogUnit);
964            }
965            if (mFeedbackLogBuffer != null) {
966                mFeedbackLogBuffer.shiftIn(mCurrentLogUnit);
967            }
968            if (mUserRecordingLogBuffer != null) {
969                mUserRecordingLogBuffer.shiftIn(mCurrentLogUnit);
970            }
971            mCurrentLogUnit = new LogUnit();
972        } else {
973            if (DEBUG) {
974                Log.d(TAG, "Warning: tried to commit empty log unit.");
975            }
976        }
977    }
978
979    private static final LogStatement LOGSTATEMENT_UNCOMMIT_CURRENT_LOGUNIT =
980            new LogStatement("UncommitCurrentLogUnit", false, false);
981    public void uncommitCurrentLogUnit(final String expectedWord,
982            final boolean dumpCurrentLogUnit) {
983        // The user has deleted this word and returned to the previous.  Check that the word in the
984        // logUnit matches the expected word.  If so, restore the last log unit committed to be the
985        // current logUnit.  I.e., pull out the last LogUnit from all the LogBuffers, and make
986        // restore it to mCurrentLogUnit so the new edits are captured with the word.  Optionally
987        // dump the contents of mCurrentLogUnit (useful if they contain deletions of the next word
988        // that should not be reported to protect user privacy)
989        //
990        // Note that we don't use mLastLogUnit here, because it only goes one word back and is only
991        // needed for reverts, which only happen one back.
992        if (mMainLogBuffer == null) {
993            return;
994        }
995        final LogUnit oldLogUnit = mMainLogBuffer.peekLastLogUnit();
996
997        // Check that expected word matches.
998        if (oldLogUnit != null) {
999            final String oldLogUnitWord = oldLogUnit.getWord();
1000            if (!oldLogUnitWord.equals(expectedWord)) {
1001                return;
1002            }
1003        }
1004
1005        // Uncommit, merging if necessary.
1006        mMainLogBuffer.unshiftIn();
1007        if (oldLogUnit != null && !dumpCurrentLogUnit) {
1008            oldLogUnit.append(mCurrentLogUnit);
1009            mSavedDownEventTime = Long.MAX_VALUE;
1010        }
1011        if (oldLogUnit == null) {
1012            mCurrentLogUnit = new LogUnit();
1013        } else {
1014            mCurrentLogUnit = oldLogUnit;
1015        }
1016        if (mFeedbackLogBuffer != null) {
1017            mFeedbackLogBuffer.unshiftIn();
1018        }
1019        enqueueEvent(LOGSTATEMENT_UNCOMMIT_CURRENT_LOGUNIT);
1020        if (DEBUG) {
1021            Log.d(TAG, "uncommitCurrentLogUnit (dump=" + dumpCurrentLogUnit + ") back to "
1022                    + (mCurrentLogUnit.hasWord() ? ": '" + mCurrentLogUnit.getWord() + "'" : ""));
1023        }
1024    }
1025
1026    /**
1027     * Publish all the logUnits in the logBuffer, without doing any privacy filtering.
1028     */
1029    /* package for test */ void publishLogBuffer(final LogBuffer logBuffer,
1030            final ResearchLog researchLog, final boolean canIncludePrivateData) {
1031        publishLogUnits(logBuffer.getLogUnits(), researchLog, canIncludePrivateData);
1032    }
1033
1034    private static final LogStatement LOGSTATEMENT_LOG_SEGMENT_OPENING =
1035            new LogStatement("logSegmentStart", false, false, "isIncludingPrivateData");
1036    private static final LogStatement LOGSTATEMENT_LOG_SEGMENT_CLOSING =
1037            new LogStatement("logSegmentEnd", false, false);
1038    /**
1039     * Publish all LogUnits in a list.
1040     *
1041     * Any privacy checks should be performed before calling this method.
1042     */
1043    /* package for test */ void publishLogUnits(final List<LogUnit> logUnits,
1044            final ResearchLog researchLog, final boolean canIncludePrivateData) {
1045        final LogUnit openingLogUnit = new LogUnit();
1046        if (logUnits.isEmpty()) return;
1047        // LogUnits not containing private data, such as contextual data for the log, do not require
1048        // logSegment boundary statements.
1049        if (canIncludePrivateData) {
1050            openingLogUnit.addLogStatement(LOGSTATEMENT_LOG_SEGMENT_OPENING,
1051                    SystemClock.uptimeMillis(), canIncludePrivateData);
1052            researchLog.publish(openingLogUnit, true /* isIncludingPrivateData */);
1053        }
1054        for (LogUnit logUnit : logUnits) {
1055            if (DEBUG) {
1056                Log.d(TAG, "publishLogBuffer: " + (logUnit.hasWord() ? logUnit.getWord()
1057                        : "<wordless>") + ", correction?: " + logUnit.containsCorrection());
1058            }
1059            researchLog.publish(logUnit, canIncludePrivateData);
1060        }
1061        if (canIncludePrivateData) {
1062            final LogUnit closingLogUnit = new LogUnit();
1063            closingLogUnit.addLogStatement(LOGSTATEMENT_LOG_SEGMENT_CLOSING,
1064                    SystemClock.uptimeMillis());
1065            researchLog.publish(closingLogUnit, true /* isIncludingPrivateData */);
1066        }
1067    }
1068
1069    public static boolean hasLetters(final String word) {
1070        final int length = word.length();
1071        for (int i = 0; i < length; i = word.offsetByCodePoints(i, 1)) {
1072            final int codePoint = word.codePointAt(i);
1073            if (Character.isLetter(codePoint)) {
1074                return true;
1075            }
1076        }
1077        return false;
1078    }
1079
1080    /**
1081     * Commit the portion of mCurrentLogUnit before maxTime as a worded logUnit.
1082     *
1083     * After this operation completes, mCurrentLogUnit will hold any logStatements that happened
1084     * after maxTime.
1085     */
1086    /* package for test */ void commitCurrentLogUnitAsWord(final String word, final long maxTime,
1087            final boolean isBatchMode) {
1088        if (word == null) {
1089            return;
1090        }
1091        if (word.length() > 0 && hasLetters(word)) {
1092            mCurrentLogUnit.setWord(word);
1093        }
1094        final LogUnit newLogUnit = mCurrentLogUnit.splitByTime(maxTime);
1095        enqueueCommitText(word, isBatchMode);
1096        commitCurrentLogUnit();
1097        mCurrentLogUnit = newLogUnit;
1098    }
1099
1100    /**
1101     * Record the time of a MotionEvent.ACTION_DOWN.
1102     *
1103     * Warning: Not thread safe.  Only call from the main thread.
1104     */
1105    private void setSavedDownEventTime(final long time) {
1106        mSavedDownEventTime = time;
1107    }
1108
1109    public void onWordFinished(final String word, final boolean isBatchMode) {
1110        commitCurrentLogUnitAsWord(word, mSavedDownEventTime, isBatchMode);
1111        mSavedDownEventTime = Long.MAX_VALUE;
1112    }
1113
1114    private static int scrubDigitFromCodePoint(int codePoint) {
1115        return Character.isDigit(codePoint) ? DIGIT_REPLACEMENT_CODEPOINT : codePoint;
1116    }
1117
1118    /* package for test */ static String scrubDigitsFromString(String s) {
1119        StringBuilder sb = null;
1120        final int length = s.length();
1121        for (int i = 0; i < length; i = s.offsetByCodePoints(i, 1)) {
1122            final int codePoint = Character.codePointAt(s, i);
1123            if (Character.isDigit(codePoint)) {
1124                if (sb == null) {
1125                    sb = new StringBuilder(length);
1126                    sb.append(s.substring(0, i));
1127                }
1128                sb.appendCodePoint(DIGIT_REPLACEMENT_CODEPOINT);
1129            } else {
1130                if (sb != null) {
1131                    sb.appendCodePoint(codePoint);
1132                }
1133            }
1134        }
1135        if (sb == null) {
1136            return s;
1137        } else {
1138            return sb.toString();
1139        }
1140    }
1141
1142    private static String getUUID(final SharedPreferences prefs) {
1143        String uuidString = prefs.getString(PREF_RESEARCH_LOGGER_UUID_STRING, null);
1144        if (null == uuidString) {
1145            UUID uuid = UUID.randomUUID();
1146            uuidString = uuid.toString();
1147            Editor editor = prefs.edit();
1148            editor.putString(PREF_RESEARCH_LOGGER_UUID_STRING, uuidString);
1149            editor.apply();
1150        }
1151        return uuidString;
1152    }
1153
1154    private String scrubWord(String word) {
1155        final Dictionary dictionary = getDictionary();
1156        if (dictionary == null) {
1157            return WORD_REPLACEMENT_STRING;
1158        }
1159        if (dictionary.isValidWord(word)) {
1160            return word;
1161        }
1162        return WORD_REPLACEMENT_STRING;
1163    }
1164
1165    // Specific logging methods follow below.  The comments for each logging method should
1166    // indicate what specific method is logged, and how to trigger it from the user interface.
1167    //
1168    // Logging methods can be generally classified into two flavors, "UserAction", which should
1169    // correspond closely to an event that is sensed by the IME, and is usually generated
1170    // directly by the user, and "SystemResponse" which corresponds to an event that the IME
1171    // generates, often after much processing of user input.  SystemResponses should correspond
1172    // closely to user-visible events.
1173    // TODO: Consider exposing the UserAction classification in the log output.
1174
1175    /**
1176     * Log a call to LatinIME.onStartInputViewInternal().
1177     *
1178     * UserAction: called each time the keyboard is opened up.
1179     */
1180    private static final LogStatement LOGSTATEMENT_LATIN_IME_ON_START_INPUT_VIEW_INTERNAL =
1181            new LogStatement("LatinImeOnStartInputViewInternal", false, false, "uuid",
1182                    "packageName", "inputType", "imeOptions", "fieldId", "display", "model",
1183                    "prefs", "versionCode", "versionName", "outputFormatVersion", "logEverything",
1184                    "isUsingDevelopmentOnlyDiagnosticsDebug");
1185    public static void latinIME_onStartInputViewInternal(final EditorInfo editorInfo,
1186            final SharedPreferences prefs) {
1187        final ResearchLogger researchLogger = getInstance();
1188        if (editorInfo != null) {
1189            final boolean isPassword = InputTypeUtils.isPasswordInputType(editorInfo.inputType)
1190                    || InputTypeUtils.isVisiblePasswordInputType(editorInfo.inputType);
1191            getInstance().setIsPasswordView(isPassword);
1192            researchLogger.start();
1193            final Context context = researchLogger.mLatinIME;
1194            try {
1195                final PackageInfo packageInfo;
1196                packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(),
1197                        0);
1198                final Integer versionCode = packageInfo.versionCode;
1199                final String versionName = packageInfo.versionName;
1200                researchLogger.enqueueEvent(LOGSTATEMENT_LATIN_IME_ON_START_INPUT_VIEW_INTERNAL,
1201                        researchLogger.mUUIDString, editorInfo.packageName,
1202                        Integer.toHexString(editorInfo.inputType),
1203                        Integer.toHexString(editorInfo.imeOptions), editorInfo.fieldId,
1204                        Build.DISPLAY, Build.MODEL, prefs, versionCode, versionName,
1205                        OUTPUT_FORMAT_VERSION, IS_LOGGING_EVERYTHING,
1206                        ProductionFlag.USES_DEVELOPMENT_ONLY_DIAGNOSTICS_DEBUG);
1207            } catch (NameNotFoundException e) {
1208                e.printStackTrace();
1209            }
1210        }
1211    }
1212
1213    public void latinIME_onFinishInputViewInternal() {
1214        stop();
1215    }
1216
1217    /**
1218     * Log a change in preferences.
1219     *
1220     * UserAction: called when the user changes the settings.
1221     */
1222    private static final LogStatement LOGSTATEMENT_PREFS_CHANGED =
1223            new LogStatement("PrefsChanged", false, false, "prefs");
1224    public static void prefsChanged(final SharedPreferences prefs) {
1225        final ResearchLogger researchLogger = getInstance();
1226        researchLogger.enqueueEvent(LOGSTATEMENT_PREFS_CHANGED, prefs);
1227    }
1228
1229    /**
1230     * Log a call to MainKeyboardView.processMotionEvent().
1231     *
1232     * UserAction: called when the user puts their finger onto the screen (ACTION_DOWN).
1233     *
1234     */
1235    private static final LogStatement LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT =
1236            new LogStatement("MotionEvent", true, false, "action",
1237                    LogStatement.KEY_IS_LOGGING_RELATED, "motionEvent");
1238    public static void mainKeyboardView_processMotionEvent(final MotionEvent me, final int action,
1239            final long eventTime, final int index, final int id, final int x, final int y) {
1240        if (me != null) {
1241            final String actionString = LoggingUtils.getMotionEventActionTypeString(action);
1242            final ResearchLogger researchLogger = getInstance();
1243            researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT,
1244                    actionString, false /* IS_LOGGING_RELATED */, MotionEvent.obtain(me));
1245            if (action == MotionEvent.ACTION_DOWN) {
1246                // Subtract 1 from eventTime so the down event is included in the later
1247                // LogUnit, not the earlier (the test is for inequality).
1248                researchLogger.setSavedDownEventTime(eventTime - 1);
1249            }
1250            // Refresh the timer in case we are capturing user feedback.
1251            if (researchLogger.isMakingUserRecording()) {
1252                researchLogger.resetRecordingTimer();
1253            }
1254        }
1255    }
1256
1257    /**
1258     * Log a call to LatinIME.onCodeInput().
1259     *
1260     * SystemResponse: The main processing step for entering text.  Called when the user performs a
1261     * tap, a flick, a long press, releases a gesture, or taps a punctuation suggestion.
1262     */
1263    private static final LogStatement LOGSTATEMENT_LATIN_IME_ON_CODE_INPUT =
1264            new LogStatement("LatinImeOnCodeInput", true, false, "code", "x", "y");
1265    public static void latinIME_onCodeInput(final int code, final int x, final int y) {
1266        final long time = SystemClock.uptimeMillis();
1267        final ResearchLogger researchLogger = getInstance();
1268        researchLogger.enqueueEvent(LOGSTATEMENT_LATIN_IME_ON_CODE_INPUT,
1269                Constants.printableCode(scrubDigitFromCodePoint(code)), x, y);
1270        if (Character.isDigit(code)) {
1271            researchLogger.setCurrentLogUnitContainsDigitFlag();
1272        }
1273        researchLogger.mStatistics.recordChar(code, time);
1274    }
1275    /**
1276     * Log a call to LatinIME.onDisplayCompletions().
1277     *
1278     * SystemResponse: The IME has displayed application-specific completions.  They may show up
1279     * in the suggestion strip, such as a landscape phone.
1280     */
1281    private static final LogStatement LOGSTATEMENT_LATINIME_ONDISPLAYCOMPLETIONS =
1282            new LogStatement("LatinIMEOnDisplayCompletions", true, true,
1283                    "applicationSpecifiedCompletions");
1284    public static void latinIME_onDisplayCompletions(
1285            final CompletionInfo[] applicationSpecifiedCompletions) {
1286        // Note; passing an array as a single element in a vararg list.  Must create a new
1287        // dummy array around it or it will get expanded.
1288        getInstance().enqueueEvent(LOGSTATEMENT_LATINIME_ONDISPLAYCOMPLETIONS,
1289                new Object[] { applicationSpecifiedCompletions });
1290    }
1291
1292    public static boolean getAndClearLatinIMEExpectingUpdateSelection() {
1293        boolean returnValue = sLatinIMEExpectingUpdateSelection;
1294        sLatinIMEExpectingUpdateSelection = false;
1295        return returnValue;
1296    }
1297
1298    /**
1299     * Log a call to LatinIME.onWindowHidden().
1300     *
1301     * UserAction: The user has performed an action that has caused the IME to be closed.  They may
1302     * have focused on something other than a text field, or explicitly closed it.
1303     */
1304    private static final LogStatement LOGSTATEMENT_LATINIME_ONWINDOWHIDDEN =
1305            new LogStatement("LatinIMEOnWindowHidden", false, false, "isTextTruncated", "text");
1306    public static void latinIME_onWindowHidden(final int savedSelectionStart,
1307            final int savedSelectionEnd, final InputConnection ic) {
1308        if (ic != null) {
1309            final boolean isTextTruncated;
1310            final String text;
1311            if (LOG_FULL_TEXTVIEW_CONTENTS) {
1312                // Capture the TextView contents.  This will trigger onUpdateSelection(), so we
1313                // set sLatinIMEExpectingUpdateSelection so that when onUpdateSelection() is called,
1314                // it can tell that it was generated by the logging code, and not by the user, and
1315                // therefore keep user-visible state as is.
1316                ic.beginBatchEdit();
1317                ic.performContextMenuAction(android.R.id.selectAll);
1318                CharSequence charSequence = ic.getSelectedText(0);
1319                if (savedSelectionStart != -1 && savedSelectionEnd != -1) {
1320                    ic.setSelection(savedSelectionStart, savedSelectionEnd);
1321                }
1322                ic.endBatchEdit();
1323                sLatinIMEExpectingUpdateSelection = true;
1324                if (TextUtils.isEmpty(charSequence)) {
1325                    isTextTruncated = false;
1326                    text = "";
1327                } else {
1328                    if (charSequence.length() > MAX_INPUTVIEW_LENGTH_TO_CAPTURE) {
1329                        int length = MAX_INPUTVIEW_LENGTH_TO_CAPTURE;
1330                        // do not cut in the middle of a supplementary character
1331                        final char c = charSequence.charAt(length - 1);
1332                        if (Character.isHighSurrogate(c)) {
1333                            length--;
1334                        }
1335                        final CharSequence truncatedCharSequence = charSequence.subSequence(0,
1336                                length);
1337                        isTextTruncated = true;
1338                        text = truncatedCharSequence.toString();
1339                    } else {
1340                        isTextTruncated = false;
1341                        text = charSequence.toString();
1342                    }
1343                }
1344            } else {
1345                isTextTruncated = true;
1346                text = "";
1347            }
1348            final ResearchLogger researchLogger = getInstance();
1349            // Assume that OUTPUT_ENTIRE_BUFFER is only true when we don't care about privacy (e.g.
1350            // during a live user test), so the normal isPotentiallyPrivate and
1351            // isPotentiallyRevealing flags do not apply
1352            researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONWINDOWHIDDEN, isTextTruncated,
1353                    text);
1354            researchLogger.commitCurrentLogUnit();
1355            getInstance().stop();
1356        }
1357    }
1358
1359    /**
1360     * Log a call to LatinIME.onUpdateSelection().
1361     *
1362     * UserAction/SystemResponse: The user has moved the cursor or selection.  This function may
1363     * be called, however, when the system has moved the cursor, say by inserting a character.
1364     */
1365    private static final LogStatement LOGSTATEMENT_LATINIME_ONUPDATESELECTION =
1366            new LogStatement("LatinIMEOnUpdateSelection", true, false, "lastSelectionStart",
1367                    "lastSelectionEnd", "oldSelStart", "oldSelEnd", "newSelStart", "newSelEnd",
1368                    "composingSpanStart", "composingSpanEnd", "expectingUpdateSelection",
1369                    "expectingUpdateSelectionFromLogger", "context");
1370    public static void latinIME_onUpdateSelection(final int lastSelectionStart,
1371            final int lastSelectionEnd, final int oldSelStart, final int oldSelEnd,
1372            final int newSelStart, final int newSelEnd, final int composingSpanStart,
1373            final int composingSpanEnd, final boolean expectingUpdateSelection,
1374            final boolean expectingUpdateSelectionFromLogger,
1375            final RichInputConnection connection) {
1376        String word = "";
1377        if (connection != null) {
1378            Range range = connection.getWordRangeAtCursor(WHITESPACE_SEPARATORS, 1);
1379            if (range != null) {
1380                word = range.mWord;
1381            }
1382        }
1383        final ResearchLogger researchLogger = getInstance();
1384        final String scrubbedWord = researchLogger.scrubWord(word);
1385        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONUPDATESELECTION, lastSelectionStart,
1386                lastSelectionEnd, oldSelStart, oldSelEnd, newSelStart, newSelEnd,
1387                composingSpanStart, composingSpanEnd, expectingUpdateSelection,
1388                expectingUpdateSelectionFromLogger, scrubbedWord);
1389    }
1390
1391    /**
1392     * Log a call to LatinIME.onTextInput().
1393     *
1394     * SystemResponse: Raw text is added to the TextView.
1395     */
1396    public static void latinIME_onTextInput(final String text, final boolean isBatchMode) {
1397        final ResearchLogger researchLogger = getInstance();
1398        researchLogger.commitCurrentLogUnitAsWord(text, Long.MAX_VALUE, isBatchMode);
1399    }
1400
1401    /**
1402     * Log a call to LatinIME.pickSuggestionManually().
1403     *
1404     * UserAction: The user has chosen a specific word from the suggestion strip.
1405     */
1406    private static final LogStatement LOGSTATEMENT_LATINIME_PICKSUGGESTIONMANUALLY =
1407            new LogStatement("LatinIMEPickSuggestionManually", true, false, "replacedWord", "index",
1408                    "suggestion", "x", "y", "isBatchMode");
1409    public static void latinIME_pickSuggestionManually(final String replacedWord,
1410            final int index, final String suggestion, final boolean isBatchMode) {
1411        final ResearchLogger researchLogger = getInstance();
1412        if (!replacedWord.equals(suggestion.toString())) {
1413            // The user chose something other than what was already there.
1414            researchLogger.setCurrentLogUnitContainsCorrection();
1415            researchLogger.setCurrentLogUnitCorrectionType(LogUnit.CORRECTIONTYPE_TYPO);
1416        }
1417        final String scrubbedWord = scrubDigitsFromString(suggestion);
1418        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_PICKSUGGESTIONMANUALLY,
1419                scrubDigitsFromString(replacedWord), index,
1420                suggestion == null ? null : scrubbedWord, Constants.SUGGESTION_STRIP_COORDINATE,
1421                Constants.SUGGESTION_STRIP_COORDINATE, isBatchMode);
1422        researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, Long.MAX_VALUE, isBatchMode);
1423        researchLogger.mStatistics.recordManualSuggestion(SystemClock.uptimeMillis());
1424    }
1425
1426    /**
1427     * Log a call to LatinIME.punctuationSuggestion().
1428     *
1429     * UserAction: The user has chosen punctuation from the punctuation suggestion strip.
1430     */
1431    private static final LogStatement LOGSTATEMENT_LATINIME_PUNCTUATIONSUGGESTION =
1432            new LogStatement("LatinIMEPunctuationSuggestion", false, false, "index", "suggestion",
1433                    "x", "y", "isPrediction");
1434    public static void latinIME_punctuationSuggestion(final int index, final String suggestion,
1435            final boolean isBatchMode, final boolean isPrediction) {
1436        final ResearchLogger researchLogger = getInstance();
1437        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_PUNCTUATIONSUGGESTION, index, suggestion,
1438                Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE,
1439                isPrediction);
1440        researchLogger.commitCurrentLogUnitAsWord(suggestion, Long.MAX_VALUE, isBatchMode);
1441    }
1442
1443    /**
1444     * Log a call to LatinIME.sendKeyCodePoint().
1445     *
1446     * SystemResponse: The IME is inserting text into the TextView for numbers, fixed strings, or
1447     * some other unusual mechanism.
1448     */
1449    private static final LogStatement LOGSTATEMENT_LATINIME_SENDKEYCODEPOINT =
1450            new LogStatement("LatinIMESendKeyCodePoint", true, false, "code");
1451    public static void latinIME_sendKeyCodePoint(final int code) {
1452        final ResearchLogger researchLogger = getInstance();
1453        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_SENDKEYCODEPOINT,
1454                Constants.printableCode(scrubDigitFromCodePoint(code)));
1455        if (Character.isDigit(code)) {
1456            researchLogger.setCurrentLogUnitContainsDigitFlag();
1457        }
1458    }
1459
1460    /**
1461     * Log a call to LatinIME.promotePhantomSpace().
1462     *
1463     * SystemResponse: The IME is inserting a real space in place of a phantom space.
1464     */
1465    private static final LogStatement LOGSTATEMENT_LATINIME_PROMOTEPHANTOMSPACE =
1466            new LogStatement("LatinIMEPromotPhantomSpace", false, false);
1467    public static void latinIME_promotePhantomSpace() {
1468        final ResearchLogger researchLogger = getInstance();
1469        final LogUnit logUnit;
1470        if (researchLogger.mMainLogBuffer == null) {
1471            logUnit = researchLogger.mCurrentLogUnit;
1472        } else {
1473            logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit();
1474        }
1475        researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_PROMOTEPHANTOMSPACE);
1476    }
1477
1478    /**
1479     * Log a call to LatinIME.swapSwapperAndSpace().
1480     *
1481     * SystemResponse: A symbol has been swapped with a space character.  E.g. punctuation may swap
1482     * if a soft space is inserted after a word.
1483     */
1484    private static final LogStatement LOGSTATEMENT_LATINIME_SWAPSWAPPERANDSPACE =
1485            new LogStatement("LatinIMESwapSwapperAndSpace", false, false, "originalCharacters",
1486                    "charactersAfterSwap");
1487    public static void latinIME_swapSwapperAndSpace(final CharSequence originalCharacters,
1488            final String charactersAfterSwap) {
1489        final ResearchLogger researchLogger = getInstance();
1490        final LogUnit logUnit;
1491        if (researchLogger.mMainLogBuffer == null) {
1492            logUnit = null;
1493        } else {
1494            logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit();
1495        }
1496        if (logUnit != null) {
1497            researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_SWAPSWAPPERANDSPACE,
1498                    originalCharacters, charactersAfterSwap);
1499        }
1500    }
1501
1502    /**
1503     * Log a call to LatinIME.maybeDoubleSpacePeriod().
1504     *
1505     * SystemResponse: Two spaces have been replaced by period space.
1506     */
1507    public static void latinIME_maybeDoubleSpacePeriod(final String text,
1508            final boolean isBatchMode) {
1509        final ResearchLogger researchLogger = getInstance();
1510        researchLogger.commitCurrentLogUnitAsWord(text, Long.MAX_VALUE, isBatchMode);
1511    }
1512
1513    /**
1514     * Log a call to MainKeyboardView.onLongPress().
1515     *
1516     * UserAction: The user has performed a long-press on a key.
1517     */
1518    private static final LogStatement LOGSTATEMENT_MAINKEYBOARDVIEW_ONLONGPRESS =
1519            new LogStatement("MainKeyboardViewOnLongPress", false, false);
1520    public static void mainKeyboardView_onLongPress() {
1521        getInstance().enqueueEvent(LOGSTATEMENT_MAINKEYBOARDVIEW_ONLONGPRESS);
1522    }
1523
1524    /**
1525     * Log a call to MainKeyboardView.setKeyboard().
1526     *
1527     * SystemResponse: The IME has switched to a new keyboard (e.g. French, English).
1528     * This is typically called right after LatinIME.onStartInputViewInternal (when starting a new
1529     * IME), but may happen at other times if the user explicitly requests a keyboard change.
1530     */
1531    private static final LogStatement LOGSTATEMENT_MAINKEYBOARDVIEW_SETKEYBOARD =
1532            new LogStatement("MainKeyboardViewSetKeyboard", false, false, "elementId", "locale",
1533                    "orientation", "width", "modeName", "action", "navigateNext",
1534                    "navigatePrevious", "clobberSettingsKey", "passwordInput", "shortcutKeyEnabled",
1535                    "hasShortcutKey", "languageSwitchKeyEnabled", "isMultiLine", "tw", "th",
1536                    "keys");
1537    public static void mainKeyboardView_setKeyboard(final Keyboard keyboard) {
1538        final KeyboardId kid = keyboard.mId;
1539        final boolean isPasswordView = kid.passwordInput();
1540        final ResearchLogger researchLogger = getInstance();
1541        researchLogger.setIsPasswordView(isPasswordView);
1542        researchLogger.enqueueEvent(LOGSTATEMENT_MAINKEYBOARDVIEW_SETKEYBOARD,
1543                KeyboardId.elementIdToName(kid.mElementId),
1544                kid.mLocale + ":" + kid.mSubtype.getExtraValueOf(KEYBOARD_LAYOUT_SET),
1545                kid.mOrientation, kid.mWidth, KeyboardId.modeName(kid.mMode), kid.imeAction(),
1546                kid.navigateNext(), kid.navigatePrevious(), kid.mClobberSettingsKey,
1547                isPasswordView, kid.mShortcutKeyEnabled, kid.mHasShortcutKey,
1548                kid.mLanguageSwitchKeyEnabled, kid.isMultiLine(), keyboard.mOccupiedWidth,
1549                keyboard.mOccupiedHeight, keyboard.mKeys);
1550    }
1551
1552    /**
1553     * Log a call to LatinIME.revertCommit().
1554     *
1555     * SystemResponse: The IME has reverted commited text.  This happens when the user enters
1556     * a word, commits it by pressing space or punctuation, and then reverts the commit by hitting
1557     * backspace.
1558     */
1559    private static final LogStatement LOGSTATEMENT_LATINIME_REVERTCOMMIT =
1560            new LogStatement("LatinIMERevertCommit", true, false, "committedWord",
1561                    "originallyTypedWord", "separatorString");
1562    public static void latinIME_revertCommit(final String committedWord,
1563            final String originallyTypedWord, final boolean isBatchMode,
1564            final String separatorString) {
1565        final ResearchLogger researchLogger = getInstance();
1566        // TODO: Verify that mCurrentLogUnit has been restored and contains the reverted word.
1567        final LogUnit logUnit;
1568        if (researchLogger.mMainLogBuffer == null) {
1569            logUnit = null;
1570        } else {
1571            logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit();
1572        }
1573        if (originallyTypedWord.length() > 0 && hasLetters(originallyTypedWord)) {
1574            if (logUnit != null) {
1575                logUnit.setWord(originallyTypedWord);
1576            }
1577        }
1578        researchLogger.enqueueEvent(logUnit != null ? logUnit : researchLogger.mCurrentLogUnit,
1579                LOGSTATEMENT_LATINIME_REVERTCOMMIT, committedWord, originallyTypedWord,
1580                separatorString);
1581        if (logUnit != null) {
1582            logUnit.setContainsCorrection();
1583        }
1584        researchLogger.mStatistics.recordRevertCommit(SystemClock.uptimeMillis());
1585        researchLogger.commitCurrentLogUnitAsWord(originallyTypedWord, Long.MAX_VALUE, isBatchMode);
1586    }
1587
1588    /**
1589     * Log a call to PointerTracker.callListenerOnCancelInput().
1590     *
1591     * UserAction: The user has canceled the input, e.g., by pressing down, but then removing
1592     * outside the keyboard area.
1593     * TODO: Verify
1594     */
1595    private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCANCELINPUT =
1596            new LogStatement("PointerTrackerCallListenerOnCancelInput", false, false);
1597    public static void pointerTracker_callListenerOnCancelInput() {
1598        getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCANCELINPUT);
1599    }
1600
1601    /**
1602     * Log a call to PointerTracker.callListenerOnCodeInput().
1603     *
1604     * SystemResponse: The user has entered a key through the normal tapping mechanism.
1605     * LatinIME.onCodeInput will also be called.
1606     */
1607    private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT =
1608            new LogStatement("PointerTrackerCallListenerOnCodeInput", true, false, "code",
1609                    "outputText", "x", "y", "ignoreModifierKey", "altersCode", "isEnabled");
1610    public static void pointerTracker_callListenerOnCodeInput(final Key key, final int x,
1611            final int y, final boolean ignoreModifierKey, final boolean altersCode,
1612            final int code) {
1613        if (key != null) {
1614            String outputText = key.getOutputText();
1615            final ResearchLogger researchLogger = getInstance();
1616            researchLogger.enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT,
1617                    Constants.printableCode(scrubDigitFromCodePoint(code)),
1618                    outputText == null ? null : scrubDigitsFromString(outputText.toString()),
1619                    x, y, ignoreModifierKey, altersCode, key.isEnabled());
1620            if (code == Constants.CODE_RESEARCH) {
1621                researchLogger.suppressResearchKeyMotionData();
1622            }
1623        }
1624    }
1625
1626    private void suppressResearchKeyMotionData() {
1627        mCurrentLogUnit.removeResearchButtonInvocation();
1628    }
1629
1630    /**
1631     * Log a call to PointerTracker.callListenerCallListenerOnRelease().
1632     *
1633     * UserAction: The user has released their finger or thumb from the screen.
1634     */
1635    private static final LogStatement LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONRELEASE =
1636            new LogStatement("PointerTrackerCallListenerOnRelease", true, false, "code",
1637                    "withSliding", "ignoreModifierKey", "isEnabled");
1638    public static void pointerTracker_callListenerOnRelease(final Key key, final int primaryCode,
1639            final boolean withSliding, final boolean ignoreModifierKey) {
1640        if (key != null) {
1641            getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONRELEASE,
1642                    Constants.printableCode(scrubDigitFromCodePoint(primaryCode)), withSliding,
1643                    ignoreModifierKey, key.isEnabled());
1644        }
1645    }
1646
1647    /**
1648     * Log a call to PointerTracker.onDownEvent().
1649     *
1650     * UserAction: The user has pressed down on a key.
1651     * TODO: Differentiate with LatinIME.processMotionEvent.
1652     */
1653    private static final LogStatement LOGSTATEMENT_POINTERTRACKER_ONDOWNEVENT =
1654            new LogStatement("PointerTrackerOnDownEvent", true, false, "deltaT", "distanceSquared");
1655    public static void pointerTracker_onDownEvent(long deltaT, int distanceSquared) {
1656        getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_ONDOWNEVENT, deltaT,
1657                distanceSquared);
1658    }
1659
1660    /**
1661     * Log a call to PointerTracker.onMoveEvent().
1662     *
1663     * UserAction: The user has moved their finger while pressing on the screen.
1664     * TODO: Differentiate with LatinIME.processMotionEvent().
1665     */
1666    private static final LogStatement LOGSTATEMENT_POINTERTRACKER_ONMOVEEVENT =
1667            new LogStatement("PointerTrackerOnMoveEvent", true, false, "x", "y", "lastX", "lastY");
1668    public static void pointerTracker_onMoveEvent(final int x, final int y, final int lastX,
1669            final int lastY) {
1670        getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_ONMOVEEVENT, x, y, lastX, lastY);
1671    }
1672
1673    /**
1674     * Log a call to RichInputConnection.commitCompletion().
1675     *
1676     * SystemResponse: The IME has committed a completion.  A completion is an application-
1677     * specific suggestion that is presented in a pop-up menu in the TextView.
1678     */
1679    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_COMMITCOMPLETION =
1680            new LogStatement("RichInputConnectionCommitCompletion", true, false, "completionInfo");
1681    public static void richInputConnection_commitCompletion(final CompletionInfo completionInfo) {
1682        final ResearchLogger researchLogger = getInstance();
1683        researchLogger.enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_COMMITCOMPLETION,
1684                completionInfo);
1685    }
1686
1687    /**
1688     * Log a call to RichInputConnection.revertDoubleSpacePeriod().
1689     *
1690     * SystemResponse: The IME has reverted ". ", which had previously replaced two typed spaces.
1691     */
1692    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_REVERTDOUBLESPACEPERIOD =
1693            new LogStatement("RichInputConnectionRevertDoubleSpacePeriod", false, false);
1694    public static void richInputConnection_revertDoubleSpacePeriod() {
1695        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_REVERTDOUBLESPACEPERIOD);
1696    }
1697
1698    /**
1699     * Log a call to RichInputConnection.revertSwapPunctuation().
1700     *
1701     * SystemResponse: The IME has reverted a punctuation swap.
1702     */
1703    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_REVERTSWAPPUNCTUATION =
1704            new LogStatement("RichInputConnectionRevertSwapPunctuation", false, false);
1705    public static void richInputConnection_revertSwapPunctuation() {
1706        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_REVERTSWAPPUNCTUATION);
1707    }
1708
1709    /**
1710     * Log a call to LatinIME.commitCurrentAutoCorrection().
1711     *
1712     * SystemResponse: The IME has committed an auto-correction.  An auto-correction changes the raw
1713     * text input to another word that the user more likely desired to type.
1714     */
1715    private static final LogStatement LOGSTATEMENT_LATINIME_COMMITCURRENTAUTOCORRECTION =
1716            new LogStatement("LatinIMECommitCurrentAutoCorrection", true, true, "typedWord",
1717                    "autoCorrection", "separatorString");
1718    public static void latinIme_commitCurrentAutoCorrection(final String typedWord,
1719            final String autoCorrection, final String separatorString, final boolean isBatchMode,
1720            final SuggestedWords suggestedWords) {
1721        final String scrubbedTypedWord = scrubDigitsFromString(typedWord);
1722        final String scrubbedAutoCorrection = scrubDigitsFromString(autoCorrection);
1723        final ResearchLogger researchLogger = getInstance();
1724        researchLogger.mCurrentLogUnit.initializeSuggestions(suggestedWords);
1725        researchLogger.commitCurrentLogUnitAsWord(scrubbedAutoCorrection, Long.MAX_VALUE,
1726                isBatchMode);
1727
1728        // Add the autocorrection logStatement at the end of the logUnit for the committed word.
1729        // We have to do this after calling commitCurrentLogUnitAsWord, because it may split the
1730        // current logUnit, and then we have to peek to get the logUnit reference back.
1731        final LogUnit logUnit = researchLogger.mMainLogBuffer.peekLastLogUnit();
1732        // TODO: Add test to confirm that the commitCurrentAutoCorrection log statement should
1733        // always be added to logUnit (if non-null) and not mCurrentLogUnit.
1734        researchLogger.enqueueEvent(logUnit, LOGSTATEMENT_LATINIME_COMMITCURRENTAUTOCORRECTION,
1735                scrubbedTypedWord, scrubbedAutoCorrection, separatorString);
1736    }
1737
1738    private boolean isExpectingCommitText = false;
1739    /**
1740     * Log a call to (UnknownClass).commitPartialText
1741     *
1742     * SystemResponse: The IME is committing part of a word.  This happens if a space is
1743     * automatically inserted to split a single typed string into two or more words.
1744     */
1745    // TODO: This method is currently unused.  Find where it should be called from in the IME and
1746    // add invocations.
1747    private static final LogStatement LOGSTATEMENT_COMMIT_PARTIAL_TEXT =
1748            new LogStatement("CommitPartialText", true, false, "newCursorPosition");
1749    public static void commitPartialText(final String committedWord,
1750            final long lastTimestampOfWordData, final boolean isBatchMode) {
1751        final ResearchLogger researchLogger = getInstance();
1752        final String scrubbedWord = scrubDigitsFromString(committedWord);
1753        researchLogger.enqueueEvent(LOGSTATEMENT_COMMIT_PARTIAL_TEXT);
1754        researchLogger.mStatistics.recordAutoCorrection(SystemClock.uptimeMillis());
1755        researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, lastTimestampOfWordData,
1756                isBatchMode);
1757    }
1758
1759    /**
1760     * Log a call to RichInputConnection.commitText().
1761     *
1762     * SystemResponse: The IME is committing text.  This happens after the user has typed a word
1763     * and then a space or punctuation key.
1764     */
1765    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTIONCOMMITTEXT =
1766            new LogStatement("RichInputConnectionCommitText", true, false, "newCursorPosition");
1767    public static void richInputConnection_commitText(final String committedWord,
1768            final int newCursorPosition, final boolean isBatchMode) {
1769        final ResearchLogger researchLogger = getInstance();
1770        // Only include opening and closing logSegments if private data is included
1771        final String scrubbedWord = scrubDigitsFromString(committedWord);
1772        if (!researchLogger.isExpectingCommitText) {
1773            researchLogger.enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTIONCOMMITTEXT,
1774                    newCursorPosition);
1775            researchLogger.commitCurrentLogUnitAsWord(scrubbedWord, Long.MAX_VALUE, isBatchMode);
1776        }
1777        researchLogger.isExpectingCommitText = false;
1778    }
1779
1780    /**
1781     * Shared event for logging committed text.
1782     */
1783    private static final LogStatement LOGSTATEMENT_COMMITTEXT =
1784            new LogStatement("CommitText", true, false, "committedText", "isBatchMode");
1785    private void enqueueCommitText(final String word, final boolean isBatchMode) {
1786        enqueueEvent(LOGSTATEMENT_COMMITTEXT, word, isBatchMode);
1787    }
1788
1789    /**
1790     * Log a call to RichInputConnection.deleteSurroundingText().
1791     *
1792     * SystemResponse: The IME has deleted text.
1793     */
1794    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT =
1795            new LogStatement("RichInputConnectionDeleteSurroundingText", true, false,
1796                    "beforeLength", "afterLength");
1797    public static void richInputConnection_deleteSurroundingText(final int beforeLength,
1798            final int afterLength) {
1799        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_DELETESURROUNDINGTEXT,
1800                beforeLength, afterLength);
1801    }
1802
1803    /**
1804     * Log a call to RichInputConnection.finishComposingText().
1805     *
1806     * SystemResponse: The IME has left the composing text as-is.
1807     */
1808    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT =
1809            new LogStatement("RichInputConnectionFinishComposingText", false, false);
1810    public static void richInputConnection_finishComposingText() {
1811        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_FINISHCOMPOSINGTEXT);
1812    }
1813
1814    /**
1815     * Log a call to RichInputConnection.performEditorAction().
1816     *
1817     * SystemResponse: The IME is invoking an action specific to the editor.
1818     */
1819    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_PERFORMEDITORACTION =
1820            new LogStatement("RichInputConnectionPerformEditorAction", false, false,
1821                    "imeActionId");
1822    public static void richInputConnection_performEditorAction(final int imeActionId) {
1823        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_PERFORMEDITORACTION,
1824                imeActionId);
1825    }
1826
1827    /**
1828     * Log a call to RichInputConnection.sendKeyEvent().
1829     *
1830     * SystemResponse: The IME is telling the TextView that a key is being pressed through an
1831     * alternate channel.
1832     * TODO: only for hardware keys?
1833     */
1834    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SENDKEYEVENT =
1835            new LogStatement("RichInputConnectionSendKeyEvent", true, false, "eventTime", "action",
1836                    "code");
1837    public static void richInputConnection_sendKeyEvent(final KeyEvent keyEvent) {
1838        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SENDKEYEVENT,
1839                keyEvent.getEventTime(), keyEvent.getAction(), keyEvent.getKeyCode());
1840    }
1841
1842    /**
1843     * Log a call to RichInputConnection.setComposingText().
1844     *
1845     * SystemResponse: The IME is setting the composing text.  Happens each time a character is
1846     * entered.
1847     */
1848    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SETCOMPOSINGTEXT =
1849            new LogStatement("RichInputConnectionSetComposingText", true, true, "text",
1850                    "newCursorPosition");
1851    public static void richInputConnection_setComposingText(final CharSequence text,
1852            final int newCursorPosition) {
1853        if (text == null) {
1854            throw new RuntimeException("setComposingText is null");
1855        }
1856        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SETCOMPOSINGTEXT, text,
1857                newCursorPosition);
1858    }
1859
1860    /**
1861     * Log a call to RichInputConnection.setSelection().
1862     *
1863     * SystemResponse: The IME is requesting that the selection change.  User-initiated selection-
1864     * change requests do not go through this method -- it's only when the system wants to change
1865     * the selection.
1866     */
1867    private static final LogStatement LOGSTATEMENT_RICHINPUTCONNECTION_SETSELECTION =
1868            new LogStatement("RichInputConnectionSetSelection", true, false, "from", "to");
1869    public static void richInputConnection_setSelection(final int from, final int to) {
1870        getInstance().enqueueEvent(LOGSTATEMENT_RICHINPUTCONNECTION_SETSELECTION, from, to);
1871    }
1872
1873    /**
1874     * Log a call to SuddenJumpingTouchEventHandler.onTouchEvent().
1875     *
1876     * SystemResponse: The IME has filtered input events in case of an erroneous sensor reading.
1877     */
1878    private static final LogStatement LOGSTATEMENT_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT =
1879            new LogStatement("SuddenJumpingTouchEventHandlerOnTouchEvent", true, false,
1880                    "motionEvent");
1881    public static void suddenJumpingTouchEventHandler_onTouchEvent(final MotionEvent me) {
1882        if (me != null) {
1883            getInstance().enqueueEvent(LOGSTATEMENT_SUDDENJUMPINGTOUCHEVENTHANDLER_ONTOUCHEVENT,
1884                    me.toString());
1885        }
1886    }
1887
1888    /**
1889     * Log a call to SuggestionsView.setSuggestions().
1890     *
1891     * SystemResponse: The IME is setting the suggestions in the suggestion strip.
1892     */
1893    private static final LogStatement LOGSTATEMENT_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS =
1894            new LogStatement("SuggestionStripViewSetSuggestions", true, true, "suggestedWords");
1895    public static void suggestionStripView_setSuggestions(final SuggestedWords suggestedWords) {
1896        if (suggestedWords != null) {
1897            getInstance().enqueueEvent(LOGSTATEMENT_SUGGESTIONSTRIPVIEW_SETSUGGESTIONS,
1898                    suggestedWords);
1899        }
1900    }
1901
1902    /**
1903     * The user has indicated a particular point in the log that is of interest.
1904     *
1905     * UserAction: From direct menu invocation.
1906     */
1907    private static final LogStatement LOGSTATEMENT_USER_TIMESTAMP =
1908            new LogStatement("UserTimestamp", false, false);
1909    public void userTimestamp() {
1910        getInstance().enqueueEvent(LOGSTATEMENT_USER_TIMESTAMP);
1911    }
1912
1913    /**
1914     * Log a call to LatinIME.onEndBatchInput().
1915     *
1916     * SystemResponse: The system has completed a gesture.
1917     */
1918    private static final LogStatement LOGSTATEMENT_LATINIME_ONENDBATCHINPUT =
1919            new LogStatement("LatinIMEOnEndBatchInput", true, false, "enteredText",
1920                    "enteredWordPos");
1921    public static void latinIME_onEndBatchInput(final CharSequence enteredText,
1922            final int enteredWordPos, final SuggestedWords suggestedWords) {
1923        final ResearchLogger researchLogger = getInstance();
1924        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_ONENDBATCHINPUT, enteredText,
1925                enteredWordPos);
1926        researchLogger.mCurrentLogUnit.initializeSuggestions(suggestedWords);
1927        researchLogger.mStatistics.recordGestureInput(enteredText.length(),
1928                SystemClock.uptimeMillis());
1929    }
1930
1931    /**
1932     * Log a call to LatinIME.handleBackspace() that is not a batch delete.
1933     *
1934     * UserInput: The user is deleting one or more characters by hitting the backspace key once.
1935     * The covers single character deletes as well as deleting selections.
1936     */
1937    private static final LogStatement LOGSTATEMENT_LATINIME_HANDLEBACKSPACE =
1938            new LogStatement("LatinIMEHandleBackspace", true, false, "numCharacters");
1939    public static void latinIME_handleBackspace(final int numCharacters) {
1940        final ResearchLogger researchLogger = getInstance();
1941        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLEBACKSPACE, numCharacters);
1942    }
1943
1944    /**
1945     * Log a call to LatinIME.handleBackspace() that is a batch delete.
1946     *
1947     * UserInput: The user is deleting a gestured word by hitting the backspace key once.
1948     */
1949    private static final LogStatement LOGSTATEMENT_LATINIME_HANDLEBACKSPACE_BATCH =
1950            new LogStatement("LatinIMEHandleBackspaceBatch", true, false, "deletedText",
1951                    "numCharacters");
1952    public static void latinIME_handleBackspace_batch(final CharSequence deletedText,
1953            final int numCharacters) {
1954        final ResearchLogger researchLogger = getInstance();
1955        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLEBACKSPACE_BATCH, deletedText,
1956                numCharacters);
1957        researchLogger.mStatistics.recordGestureDelete(deletedText.length(),
1958                SystemClock.uptimeMillis());
1959    }
1960
1961    /**
1962     * Log a long interval between user operation.
1963     *
1964     * UserInput: The user has not done anything for a while.
1965     */
1966    private static final LogStatement LOGSTATEMENT_ONUSERPAUSE = new LogStatement("OnUserPause",
1967            false, false, "intervalInMs");
1968    public static void onUserPause(final long interval) {
1969        final ResearchLogger researchLogger = getInstance();
1970        researchLogger.enqueueEvent(LOGSTATEMENT_ONUSERPAUSE, interval);
1971    }
1972
1973    /**
1974     * Record the current time in case the LogUnit is later split.
1975     *
1976     * If the current logUnit is split, then tapping, motion events, etc. before this time should
1977     * be assigned to one LogUnit, and events after this time should go into the following LogUnit.
1978     */
1979    public static void recordTimeForLogUnitSplit() {
1980        final ResearchLogger researchLogger = getInstance();
1981        researchLogger.setSavedDownEventTime(SystemClock.uptimeMillis());
1982        researchLogger.mSavedDownEventTime = Long.MAX_VALUE;
1983    }
1984
1985    /**
1986     * Log a call to LatinIME.handleSeparator()
1987     *
1988     * SystemResponse: The system is inserting a separator character, possibly performing auto-
1989     * correction or other actions appropriate at the end of a word.
1990     */
1991    private static final LogStatement LOGSTATEMENT_LATINIME_HANDLESEPARATOR =
1992            new LogStatement("LatinIMEHandleSeparator", false, false, "primaryCode",
1993                    "isComposingWord");
1994    public static void latinIME_handleSeparator(final int primaryCode,
1995            final boolean isComposingWord) {
1996        final ResearchLogger researchLogger = getInstance();
1997        researchLogger.enqueueEvent(LOGSTATEMENT_LATINIME_HANDLESEPARATOR, primaryCode,
1998                isComposingWord);
1999    }
2000
2001    /**
2002     * Log statistics.
2003     *
2004     * ContextualData, recorded at the end of a session.
2005     */
2006    private static final LogStatement LOGSTATEMENT_STATISTICS =
2007            new LogStatement("Statistics", false, false, "charCount", "letterCount", "numberCount",
2008                    "spaceCount", "deleteOpsCount", "wordCount", "isEmptyUponStarting",
2009                    "isEmptinessStateKnown", "averageTimeBetweenKeys", "averageTimeBeforeDelete",
2010                    "averageTimeDuringRepeatedDelete", "averageTimeAfterDelete",
2011                    "dictionaryWordCount", "splitWordsCount", "gestureInputCount",
2012                    "gestureCharsCount", "gesturesDeletedCount", "manualSuggestionsCount",
2013                    "revertCommitsCount", "correctedWordsCount", "autoCorrectionsCount");
2014    private static void logStatistics() {
2015        final ResearchLogger researchLogger = getInstance();
2016        final Statistics statistics = researchLogger.mStatistics;
2017        researchLogger.enqueueEvent(LOGSTATEMENT_STATISTICS, statistics.mCharCount,
2018                statistics.mLetterCount, statistics.mNumberCount, statistics.mSpaceCount,
2019                statistics.mDeleteKeyCount, statistics.mWordCount, statistics.mIsEmptyUponStarting,
2020                statistics.mIsEmptinessStateKnown, statistics.mKeyCounter.getAverageTime(),
2021                statistics.mBeforeDeleteKeyCounter.getAverageTime(),
2022                statistics.mDuringRepeatedDeleteKeysCounter.getAverageTime(),
2023                statistics.mAfterDeleteKeyCounter.getAverageTime(),
2024                statistics.mDictionaryWordCount, statistics.mSplitWordsCount,
2025                statistics.mGesturesInputCount, statistics.mGesturesCharsCount,
2026                statistics.mGesturesDeletedCount, statistics.mManualSuggestionsCount,
2027                statistics.mRevertCommitsCount, statistics.mCorrectedWordsCount,
2028                statistics.mAutoCorrectionsCount);
2029    }
2030}
2031