diff options
Diffstat (limited to 'java/src')
23 files changed, 1207 insertions, 478 deletions
diff --git a/java/src/com/android/inputmethod/keyboard/Key.java b/java/src/com/android/inputmethod/keyboard/Key.java index 9b971755e..1e5af5154 100644 --- a/java/src/com/android/inputmethod/keyboard/Key.java +++ b/java/src/com/android/inputmethod/keyboard/Key.java @@ -41,7 +41,6 @@ import com.android.inputmethod.keyboard.internal.KeyboardRow; import com.android.inputmethod.keyboard.internal.MoreKeySpec; import com.android.inputmethod.latin.Constants; import com.android.inputmethod.latin.R; -import com.android.inputmethod.latin.ResourceUtils; import com.android.inputmethod.latin.StringUtils; import org.xmlpull.v1.XmlPullParser; @@ -225,8 +224,8 @@ public class Key implements Comparable<Key> { public Key(final Resources res, final KeyboardParams params, final KeyboardRow row, final XmlPullParser parser) throws XmlPullParserException { final float horizontalGap = isSpacer() ? 0 : params.mHorizontalGap; - final int keyHeight = row.mRowHeight; - mHeight = keyHeight - params.mVerticalGap; + final int rowHeight = row.mRowHeight; + mHeight = rowHeight - params.mVerticalGap; final TypedArray keyAttr = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.Keyboard_Key); @@ -241,17 +240,18 @@ public class Key implements Comparable<Key> { mY = keyYPos; mWidth = Math.round(keyWidth - horizontalGap); mHitBox.set(Math.round(keyXPos), keyYPos, Math.round(keyXPos + keyWidth) + 1, - keyYPos + keyHeight); + keyYPos + rowHeight); // Update row to have current x coordinate. row.setXPos(keyXPos + keyWidth); mBackgroundType = style.getInt(keyAttr, R.styleable.Keyboard_Key_backgroundType, row.getDefaultBackgroundType()); - final int visualInsetsLeft = Math.round(ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_visualInsetsLeft, params.mBaseWidth, 0)); - final int visualInsetsRight = Math.round(ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_visualInsetsRight, params.mBaseWidth, 0)); + final int baseWidth = params.mBaseWidth; + final int visualInsetsLeft = Math.round(keyAttr.getFraction( + R.styleable.Keyboard_Key_visualInsetsLeft, baseWidth, baseWidth, 0)); + final int visualInsetsRight = Math.round(keyAttr.getFraction( + R.styleable.Keyboard_Key_visualInsetsRight, baseWidth, baseWidth, 0)); mIconId = KeySpecParser.getIconId(style.getString(keyAttr, R.styleable.Keyboard_Key_keyIcon)); final int disabledIconId = KeySpecParser.getIconId(style.getString(keyAttr, @@ -470,11 +470,11 @@ public class Key implements Comparable<Key> { } public void markAsLeftEdge(final KeyboardParams params) { - mHitBox.left = params.mHorizontalEdgesPadding; + mHitBox.left = params.mLeftPadding; } public void markAsRightEdge(final KeyboardParams params) { - mHitBox.right = params.mOccupiedWidth - params.mHorizontalEdgesPadding; + mHitBox.right = params.mOccupiedWidth - params.mRightPadding; } public void markAsTopEdge(final KeyboardParams params) { diff --git a/java/src/com/android/inputmethod/keyboard/Keyboard.java b/java/src/com/android/inputmethod/keyboard/Keyboard.java index c2036fc43..e87ecbc7e 100644 --- a/java/src/com/android/inputmethod/keyboard/Keyboard.java +++ b/java/src/com/android/inputmethod/keyboard/Keyboard.java @@ -30,11 +30,11 @@ import com.android.inputmethod.latin.Constants; * <p>The layout file for a keyboard contains XML that looks like the following snippet:</p> * <pre> * <Keyboard - * latin:keyWidth="%10p" - * latin:keyHeight="50px" - * latin:horizontalGap="2px" - * latin:verticalGap="2px" > - * <Row latin:keyWidth="32px" > + * latin:keyWidth="10%p" + * latin:rowHeight="50px" + * latin:horizontalGap="2%p" + * latin:verticalGap="2%p" > + * <Row latin:keyWidth="10%p" > * <Key latin:keyLabel="A" /> * ... * </Row> diff --git a/java/src/com/android/inputmethod/keyboard/KeyboardId.java b/java/src/com/android/inputmethod/keyboard/KeyboardId.java index 4b43bcc5c..ee8ee9a4f 100644 --- a/java/src/com/android/inputmethod/keyboard/KeyboardId.java +++ b/java/src/com/android/inputmethod/keyboard/KeyboardId.java @@ -18,6 +18,7 @@ package com.android.inputmethod.keyboard; import static com.android.inputmethod.latin.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET; +import android.content.res.Configuration; import android.text.InputType; import android.text.TextUtils; import android.view.inputmethod.EditorInfo; @@ -62,8 +63,10 @@ public final class KeyboardId { public final InputMethodSubtype mSubtype; public final Locale mLocale; public final int mDeviceFormFactor; + // TODO: Remove this member. It is used only for logging purpose. public final int mOrientation; public final int mWidth; + public final int mHeight; public final int mMode; public final int mElementId; private final EditorInfo mEditorInfo; @@ -81,7 +84,8 @@ public final class KeyboardId { mLocale = SubtypeLocale.getSubtypeLocale(mSubtype); mDeviceFormFactor = params.mDeviceFormFactor; mOrientation = params.mOrientation; - mWidth = params.mWidth; + mWidth = params.mKeyboardWidth; + mHeight = params.mKeyboardHeight; mMode = params.mMode; mElementId = elementId; mEditorInfo = params.mEditorInfo; @@ -108,6 +112,7 @@ public final class KeyboardId { id.mElementId, id.mMode, id.mWidth, + id.mHeight, id.passwordInput(), id.mClobberSettingsKey, id.mShortcutKeyEnabled, @@ -130,6 +135,7 @@ public final class KeyboardId { && other.mElementId == mElementId && other.mMode == mMode && other.mWidth == mWidth + && other.mHeight == mHeight && other.passwordInput() == passwordInput() && other.mClobberSettingsKey == mClobberSettingsKey && other.mShortcutKeyEnabled == mShortcutKeyEnabled @@ -187,11 +193,13 @@ public final class KeyboardId { @Override public String toString() { - return String.format("[%s %s:%s %s-%s:%d %s %s %s%s%s%s%s%s%s%s%s]", + final String orientation = (mOrientation == Configuration.ORIENTATION_PORTRAIT) + ? "port" : "land"; + return String.format("[%s %s:%s %s-%s:%dx%d %s %s %s%s%s%s%s%s%s%s%s]", elementIdToName(mElementId), mLocale, mSubtype.getExtraValueOf(KEYBOARD_LAYOUT_SET), - deviceFormFactor(mDeviceFormFactor), (mOrientation == 1 ? "port" : "land"), mWidth, + deviceFormFactor(mDeviceFormFactor), orientation, mWidth, mHeight, modeName(mMode), imeAction(), (navigateNext() ? "navigateNext" : ""), diff --git a/java/src/com/android/inputmethod/keyboard/KeyboardLayoutSet.java b/java/src/com/android/inputmethod/keyboard/KeyboardLayoutSet.java index f060ad083..fd9edec70 100644 --- a/java/src/com/android/inputmethod/keyboard/KeyboardLayoutSet.java +++ b/java/src/com/android/inputmethod/keyboard/KeyboardLayoutSet.java @@ -28,6 +28,7 @@ import android.content.res.Resources; import android.content.res.TypedArray; import android.content.res.XmlResourceParser; import android.text.InputType; +import android.text.TextUtils; import android.util.Log; import android.util.SparseArray; import android.util.Xml; @@ -44,6 +45,7 @@ import com.android.inputmethod.latin.InputAttributes; import com.android.inputmethod.latin.InputTypeUtils; import com.android.inputmethod.latin.LatinImeLogger; import com.android.inputmethod.latin.R; +import com.android.inputmethod.latin.ResourceUtils; import com.android.inputmethod.latin.SubtypeLocale; import com.android.inputmethod.latin.SubtypeSwitcher; import com.android.inputmethod.latin.XmlParseUtils; @@ -106,7 +108,8 @@ public final class KeyboardLayoutSet { InputMethodSubtype mSubtype; int mDeviceFormFactor; int mOrientation; - int mWidth; + int mKeyboardWidth; + int mKeyboardHeight; // Sparse array of KeyboardLayoutSet element parameters indexed by element's id. final SparseArray<ElementParams> mKeyboardLayoutSetElementIdToParamsMap = CollectionUtils.newSparseArray(); @@ -214,15 +217,43 @@ public final class KeyboardLayoutSet { mPackageName, NO_SETTINGS_KEY, mEditorInfo); } - public Builder setScreenGeometry(final int deviceFormFactor, final int orientation, - final int widthPixels) { + public Builder setScreenGeometry(final int deviceFormFactor, final int widthPixels, + final int heightPixels) { final Params params = mParams; params.mDeviceFormFactor = deviceFormFactor; - params.mOrientation = orientation; - params.mWidth = widthPixels; + params.mOrientation = (heightPixels > widthPixels) + ? Configuration.ORIENTATION_PORTRAIT : Configuration.ORIENTATION_LANDSCAPE; + setDefaultKeyboardSize(widthPixels, heightPixels); return this; } + private void setDefaultKeyboardSize(final int widthPixels, final int heightPixels) { + final String keyboardHeightString = ResourceUtils.getDeviceOverrideValue( + mResources, R.array.keyboard_heights); + final float keyboardHeight; + if (TextUtils.isEmpty(keyboardHeightString)) { + keyboardHeight = mResources.getDimension(R.dimen.keyboardHeight); + } else { + keyboardHeight = Float.parseFloat(keyboardHeightString) + * mResources.getDisplayMetrics().density; + } + final float maxKeyboardHeight = mResources.getFraction( + R.fraction.maxKeyboardHeight, heightPixels, heightPixels); + float minKeyboardHeight = mResources.getFraction( + R.fraction.minKeyboardHeight, heightPixels, heightPixels); + if (minKeyboardHeight < 0.0f) { + // Specified fraction was negative, so it should be calculated against display + // width. + minKeyboardHeight = -mResources.getFraction( + R.fraction.minKeyboardHeight, widthPixels, widthPixels); + } + // Keyboard height will not exceed maxKeyboardHeight and will not be less than + // minKeyboardHeight. + mParams.mKeyboardHeight = (int)Math.max( + Math.min(keyboardHeight, maxKeyboardHeight), minKeyboardHeight); + mParams.mKeyboardWidth = widthPixels; + } + public Builder setSubtype(final InputMethodSubtype subtype) { final boolean asciiCapable = subtype.containsExtraValueKey(ASCII_CAPABLE); @SuppressWarnings("deprecation") diff --git a/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java b/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java index 30949aea8..d15f14f88 100644 --- a/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java +++ b/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java @@ -20,6 +20,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.preference.PreferenceManager; +import android.util.DisplayMetrics; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; @@ -140,8 +141,9 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder( mThemeContext, editorInfo); final Resources res = mThemeContext.getResources(); + final DisplayMetrics dm = res.getDisplayMetrics(); builder.setScreenGeometry(res.getInteger(R.integer.config_device_form_factor), - res.getConfiguration().orientation, res.getDisplayMetrics().widthPixels); + dm.widthPixels, dm.heightPixels); builder.setSubtype(mSubtypeSwitcher.getCurrentSubtype()); builder.setOptions( settingsValues.isVoiceKeyEnabled(editorInfo), diff --git a/java/src/com/android/inputmethod/keyboard/internal/KeyboardBuilder.java b/java/src/com/android/inputmethod/keyboard/internal/KeyboardBuilder.java index 04b8ecba1..e087a4565 100644 --- a/java/src/com/android/inputmethod/keyboard/internal/KeyboardBuilder.java +++ b/java/src/com/android/inputmethod/keyboard/internal/KeyboardBuilder.java @@ -20,9 +20,7 @@ import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.content.res.XmlResourceParser; -import android.text.TextUtils; import android.util.AttributeSet; -import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; import android.util.Xml; @@ -138,7 +136,6 @@ public class KeyboardBuilder<KP extends KeyboardParams> { protected final KP mParams; protected final Context mContext; protected final Resources mResources; - private final DisplayMetrics mDisplayMetrics; private int mCurrentY = 0; private KeyboardRow mCurrentRow = null; @@ -150,7 +147,6 @@ public class KeyboardBuilder<KP extends KeyboardParams> { mContext = context; final Resources res = context.getResources(); mResources = res; - mDisplayMetrics = res.getDisplayMetrics(); mParams = params; @@ -232,63 +228,43 @@ public class KeyboardBuilder<KP extends KeyboardParams> { } private void parseKeyboardAttributes(final XmlPullParser parser) { - final int displayWidth = mDisplayMetrics.widthPixels; final TypedArray keyboardAttr = mContext.obtainStyledAttributes( Xml.asAttributeSet(parser), R.styleable.Keyboard, R.attr.keyboardStyle, R.style.Keyboard); final TypedArray keyAttr = mResources.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.Keyboard_Key); try { - final int displayHeight = mDisplayMetrics.heightPixels; - final String keyboardHeightString = ResourceUtils.getDeviceOverrideValue( - mResources, R.array.keyboard_heights); - final float keyboardHeight; - if (TextUtils.isEmpty(keyboardHeightString)) { - keyboardHeight = keyboardAttr.getDimension( - R.styleable.Keyboard_keyboardHeight, displayHeight / 2); - } else { - keyboardHeight = Float.parseFloat(keyboardHeightString) - * mDisplayMetrics.density; - } - final float maxKeyboardHeight = ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_maxKeyboardHeight, displayHeight, displayHeight / 2); - float minKeyboardHeight = ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_minKeyboardHeight, displayHeight, displayHeight / 2); - if (minKeyboardHeight < 0) { - // Specified fraction was negative, so it should be calculated against display - // width. - minKeyboardHeight = -ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_minKeyboardHeight, displayWidth, displayWidth / 2); - } final KeyboardParams params = mParams; - // Keyboard height will not exceed maxKeyboardHeight and will not be less than - // minKeyboardHeight. - params.mOccupiedHeight = (int)Math.max( - Math.min(keyboardHeight, maxKeyboardHeight), minKeyboardHeight); - params.mOccupiedWidth = params.mId.mWidth; - params.mTopPadding = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_keyboardTopPadding, params.mOccupiedHeight, 0); - params.mBottomPadding = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_keyboardBottomPadding, params.mOccupiedHeight, 0); - params.mHorizontalEdgesPadding = (int)ResourceUtils.getDimensionOrFraction( - keyboardAttr, - R.styleable.Keyboard_keyboardHorizontalEdgesPadding, - mParams.mOccupiedWidth, 0); - - params.mBaseWidth = params.mOccupiedWidth - params.mHorizontalEdgesPadding * 2 - - params.mHorizontalCenterPadding; - params.mDefaultKeyWidth = (int)ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_keyWidth, params.mBaseWidth, - params.mBaseWidth / DEFAULT_KEYBOARD_COLUMNS); - params.mHorizontalGap = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_horizontalGap, params.mBaseWidth, 0); - params.mVerticalGap = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_verticalGap, params.mOccupiedHeight, 0); - params.mBaseHeight = params.mOccupiedHeight - params.mTopPadding + final int height = params.mId.mHeight; + final int width = params.mId.mWidth; + params.mOccupiedHeight = height; + params.mOccupiedWidth = width; + params.mTopPadding = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_keyboardTopPadding, height, height, 0); + params.mBottomPadding = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_keyboardBottomPadding, height, height, 0); + params.mLeftPadding = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_keyboardLeftPadding, width, width, 0); + params.mRightPadding = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_keyboardRightPadding, width, width, 0); + + final int baseWidth = + params.mOccupiedWidth - params.mLeftPadding - params.mRightPadding; + params.mBaseWidth = baseWidth; + params.mDefaultKeyWidth = (int)keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + baseWidth, baseWidth, baseWidth / DEFAULT_KEYBOARD_COLUMNS); + params.mHorizontalGap = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_horizontalGap, baseWidth, baseWidth, 0); + // TODO: Fix keyboard geometry calculation clearer. Historically vertical gap between + // rows are determined based on the entire keyboard height including top and bottom + // paddings. + params.mVerticalGap = (int)keyboardAttr.getFraction( + R.styleable.Keyboard_verticalGap, height, height, 0); + final int baseHeight = params.mOccupiedHeight - params.mTopPadding - params.mBottomPadding + params.mVerticalGap; + params.mBaseHeight = baseHeight; params.mDefaultRowHeight = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, - R.styleable.Keyboard_rowHeight, params.mBaseHeight, - params.mBaseHeight / DEFAULT_KEYBOARD_ROWS); + R.styleable.Keyboard_rowHeight, baseHeight, baseHeight / DEFAULT_KEYBOARD_ROWS); params.mKeyVisualAttributes = KeyVisualAttributes.newInstance(keyAttr); @@ -766,7 +742,7 @@ public class KeyboardBuilder<KP extends KeyboardParams> { } private void startRow(final KeyboardRow row) { - addEdgeSpace(mParams.mHorizontalEdgesPadding, row); + addEdgeSpace(mParams.mLeftPadding, row); mCurrentRow = row; mLeftEdge = true; mRightEdgeKey = null; @@ -780,7 +756,7 @@ public class KeyboardBuilder<KP extends KeyboardParams> { mRightEdgeKey.markAsRightEdge(mParams); mRightEdgeKey = null; } - addEdgeSpace(mParams.mHorizontalEdgesPadding, row); + addEdgeSpace(mParams.mRightPadding, row); mCurrentY += row.mRowHeight; mCurrentRow = null; mTopEdge = false; diff --git a/java/src/com/android/inputmethod/keyboard/internal/KeyboardParams.java b/java/src/com/android/inputmethod/keyboard/internal/KeyboardParams.java index e13dbe5d0..15eb690e1 100644 --- a/java/src/com/android/inputmethod/keyboard/internal/KeyboardParams.java +++ b/java/src/com/android/inputmethod/keyboard/internal/KeyboardParams.java @@ -42,8 +42,8 @@ public class KeyboardParams { public int mTopPadding; public int mBottomPadding; - public int mHorizontalEdgesPadding; - public int mHorizontalCenterPadding; + public int mLeftPadding; + public int mRightPadding; public KeyVisualAttributes mKeyVisualAttributes; diff --git a/java/src/com/android/inputmethod/keyboard/internal/KeyboardRow.java b/java/src/com/android/inputmethod/keyboard/internal/KeyboardRow.java index 22780205d..855f65507 100644 --- a/java/src/com/android/inputmethod/keyboard/internal/KeyboardRow.java +++ b/java/src/com/android/inputmethod/keyboard/internal/KeyboardRow.java @@ -54,17 +54,16 @@ public final class KeyboardRow { public KeyboardRow(final Resources res, final KeyboardParams params, final XmlPullParser parser, final int y) { mParams = params; - TypedArray keyboardAttr = res.obtainAttributes(Xml.asAttributeSet(parser), + final TypedArray keyboardAttr = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.Keyboard); mRowHeight = (int)ResourceUtils.getDimensionOrFraction(keyboardAttr, R.styleable.Keyboard_rowHeight, params.mBaseHeight, params.mDefaultRowHeight); keyboardAttr.recycle(); - TypedArray keyAttr = res.obtainAttributes(Xml.asAttributeSet(parser), + final TypedArray keyAttr = res.obtainAttributes(Xml.asAttributeSet(parser), R.styleable.Keyboard_Key); - mDefaultKeyWidth = ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_keyWidth, - params.mBaseWidth, params.mDefaultKeyWidth); + mDefaultKeyWidth = keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + params.mBaseWidth, params.mBaseWidth, params.mDefaultKeyWidth); mDefaultBackgroundType = keyAttr.getInt(R.styleable.Keyboard_Key_backgroundType, Key.BACKGROUND_TYPE_NORMAL); keyAttr.recycle(); @@ -112,20 +111,19 @@ public final class KeyboardRow { } public float getKeyX(final TypedArray keyAttr) { - final int keyboardRightEdge = mParams.mOccupiedWidth - - mParams.mHorizontalEdgesPadding; if (keyAttr.hasValue(R.styleable.Keyboard_Key_keyXPos)) { - final float keyXPos = ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_keyXPos, mParams.mBaseWidth, 0); + final float keyXPos = keyAttr.getFraction(R.styleable.Keyboard_Key_keyXPos, + mParams.mBaseWidth, mParams.mBaseWidth, 0); if (keyXPos < 0) { // If keyXPos is negative, the actual x-coordinate will be // keyboardWidth + keyXPos. // keyXPos shouldn't be less than mCurrentX because drawable area for this // key starts at mCurrentX. Or, this key will overlaps the adjacent key on // its left hand side. + final int keyboardRightEdge = mParams.mOccupiedWidth - mParams.mRightPadding; return Math.max(keyXPos + keyboardRightEdge, mCurrentX); } else { - return keyXPos + mParams.mHorizontalEdgesPadding; + return keyXPos + mParams.mLeftPadding; } } return mCurrentX; @@ -140,15 +138,13 @@ public final class KeyboardRow { R.styleable.Keyboard_Key_keyWidth, KEYWIDTH_NOT_ENUM); switch (widthType) { case KEYWIDTH_FILL_RIGHT: - final int keyboardRightEdge = - mParams.mOccupiedWidth - mParams.mHorizontalEdgesPadding; // If keyWidth is fillRight, the actual key width will be determined to fill // out the area up to the right edge of the keyboard. + final int keyboardRightEdge = mParams.mOccupiedWidth - mParams.mRightPadding; return keyboardRightEdge - keyXPos; default: // KEYWIDTH_NOT_ENUM - return ResourceUtils.getDimensionOrFraction(keyAttr, - R.styleable.Keyboard_Key_keyWidth, - mParams.mBaseWidth, mDefaultKeyWidth); + return keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + mParams.mBaseWidth, mParams.mBaseWidth, mDefaultKeyWidth); } } } diff --git a/java/src/com/android/inputmethod/latin/AssetFileAddress.java b/java/src/com/android/inputmethod/latin/AssetFileAddress.java index 16296f0e2..47c750f54 100644 --- a/java/src/com/android/inputmethod/latin/AssetFileAddress.java +++ b/java/src/com/android/inputmethod/latin/AssetFileAddress.java @@ -35,11 +35,14 @@ final class AssetFileAddress { mLength = length; } + public static AssetFileAddress makeFromFile(final File file) { + if (!file.isFile()) return null; + return new AssetFileAddress(file.getAbsolutePath(), 0L, file.length()); + } + public static AssetFileAddress makeFromFileName(final String filename) { if (null == filename) return null; - final File f = new File(filename); - if (!f.isFile()) return null; - return new AssetFileAddress(filename, 0l, f.length()); + return makeFromFile(new File(filename)); } public static AssetFileAddress makeFromFileNameAndOffset(final String filename, diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java index d0bd01f6b..443ffa2e9 100644 --- a/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java +++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryFileDumper.java @@ -149,7 +149,7 @@ public final class BinaryDictionaryFileDumper { final int MODE_MAX = NONE; final Uri.Builder wordListUriBuilder = getProviderUriBuilder(id); - final String finalFileName = BinaryDictionaryGetter.getCacheFileName(id, locale, context); + final String finalFileName = DictionaryInfoUtils.getCacheFileName(id, locale, context); String tempFileName; try { tempFileName = BinaryDictionaryGetter.getTempFileName(id, context); diff --git a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java index 22b5cd55c..5da0f1be8 100644 --- a/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java +++ b/java/src/com/android/inputmethod/latin/BinaryDictionaryGetter.java @@ -66,109 +66,11 @@ final class BinaryDictionaryGetter { private BinaryDictionaryGetter() {} /** - * Returns whether we may want to use this character as part of a file name. - * - * This basically only accepts ascii letters and numbers, and rejects everything else. - */ - private static boolean isFileNameCharacter(int codePoint) { - if (codePoint >= 0x30 && codePoint <= 0x39) return true; // Digit - if (codePoint >= 0x41 && codePoint <= 0x5A) return true; // Uppercase - if (codePoint >= 0x61 && codePoint <= 0x7A) return true; // Lowercase - return codePoint == '_'; // Underscore - } - - /** - * Escapes a string for any characters that may be suspicious for a file or directory name. - * - * Concretely this does a sort of URL-encoding except it will encode everything that's not - * alphanumeric or underscore. (true URL-encoding leaves alone characters like '*', which - * we cannot allow here) - */ - // TODO: create a unit test for this method - private static String replaceFileNameDangerousCharacters(final String name) { - // This assumes '%' is fully available as a non-separator, normal - // character in a file name. This is probably true for all file systems. - final StringBuilder sb = new StringBuilder(); - final int nameLength = name.length(); - for (int i = 0; i < nameLength; i = name.offsetByCodePoints(i, 1)) { - final int codePoint = name.codePointAt(i); - if (isFileNameCharacter(codePoint)) { - sb.appendCodePoint(codePoint); - } else { - // 6 digits - unicode is limited to 21 bits - sb.append(String.format((Locale)null, "%%%1$06x", codePoint)); - } - } - return sb.toString(); - } - - /** - * Reverse escaping done by replaceFileNameDangerousCharacters. - */ - private static String getWordListIdFromFileName(final String fname) { - final StringBuilder sb = new StringBuilder(); - final int fnameLength = fname.length(); - for (int i = 0; i < fnameLength; i = fname.offsetByCodePoints(i, 1)) { - final int codePoint = fname.codePointAt(i); - if ('%' != codePoint) { - sb.appendCodePoint(codePoint); - } else { - final int encodedCodePoint = Integer.parseInt(fname.substring(i + 1, i + 7), 16); - i += 6; - sb.appendCodePoint(encodedCodePoint); - } - } - return sb.toString(); - } - - /** - * Helper method to get the top level cache directory. - */ - private static String getWordListCacheDirectory(final Context context) { - return context.getFilesDir() + File.separator + "dicts"; - } - - /** - * Find out the cache directory associated with a specific locale. - */ - private static String getCacheDirectoryForLocale(final String locale, final Context context) { - final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale); - final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator - + relativeDirectoryName; - final File directory = new File(absoluteDirectoryName); - if (!directory.exists()) { - if (!directory.mkdirs()) { - Log.e(TAG, "Could not create the directory for locale" + locale); - } - } - return absoluteDirectoryName; - } - - /** - * Generates a file name for the id and locale passed as an argument. - * - * In the current implementation the file name returned will always be unique for - * any id/locale pair, but please do not expect that the id can be the same for - * different dictionaries with different locales. An id should be unique for any - * dictionary. - * The file name is pretty much an URL-encoded version of the id inside a directory - * named like the locale, except it will also escape characters that look dangerous - * to some file systems. - * @param id the id of the dictionary for which to get a file name - * @param locale the locale for which to get the file name as a string - * @param context the context to use for getting the directory - * @return the name of the file to be created - */ - public static String getCacheFileName(String id, String locale, Context context) { - final String fileName = replaceFileNameDangerousCharacters(id); - return getCacheDirectoryForLocale(locale, context) + File.separator + fileName; - } - - /** * Generates a unique temporary file name in the app cache directory. */ public static String getTempFileName(String id, Context context) throws IOException { - return File.createTempFile(replaceFileNameDangerousCharacters(id), null).getAbsolutePath(); + return File.createTempFile(DictionaryInfoUtils.replaceFileNameDangerousCharacters(id), + null).getAbsolutePath(); } /** @@ -222,27 +124,6 @@ final class BinaryDictionaryGetter { } /** - * Helper method to the list of cache directories, one for each distinct locale. - */ - private static File[] getCachedDirectoryList(final Context context) { - return new File(getWordListCacheDirectory(context)).listFiles(); - } - - /** - * Returns the category for a given file name. - * - * This parses the file name, extracts the category, and returns it. See - * {@link #getMainDictId(Locale)} and {@link #isMainWordListId(String)}. - * @return The category as a string or null if it can't be found in the file name. - */ - private static String getCategoryFromFileName(final String fileName) { - final String id = getWordListIdFromFileName(fileName); - final String[] idArray = id.split(ID_CATEGORY_SEPARATOR); - if (2 != idArray.length) return null; - return idArray[0]; - } - - /** * Utility class for the {@link #getCachedWordLists} method */ private static final class FileAndMatchLevel { @@ -268,20 +149,21 @@ final class BinaryDictionaryGetter { * @param context the context on which to open the files upon. * @return an array of binary dictionary files, which may be empty but may not be null. */ - private static File[] getCachedWordLists(final String locale, - final Context context) { - final File[] directoryList = getCachedDirectoryList(context); + private static File[] getCachedWordLists(final String locale, final Context context) { + final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context); if (null == directoryList) return EMPTY_FILE_ARRAY; final HashMap<String, FileAndMatchLevel> cacheFiles = CollectionUtils.newHashMap(); for (File directory : directoryList) { if (!directory.isDirectory()) continue; - final String dirLocale = getWordListIdFromFileName(directory.getName()); + final String dirLocale = + DictionaryInfoUtils.getWordListIdFromFileName(directory.getName()); final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale); if (LocaleUtils.isMatch(matchLevel)) { final File[] wordLists = directory.listFiles(); if (null != wordLists) { for (File wordList : wordLists) { - final String category = getCategoryFromFileName(wordList.getName()); + final String category = + DictionaryInfoUtils.getCategoryFromFileName(wordList.getName()); final FileAndMatchLevel currentBestMatch = cacheFiles.get(category); if (null == currentBestMatch || currentBestMatch.mMatchLevel < matchLevel) { cacheFiles.put(category, new FileAndMatchLevel(wordList, matchLevel)); @@ -310,7 +192,7 @@ final class BinaryDictionaryGetter { final File fileToKeep) { try { final File canonicalFileToKeep = fileToKeep.getCanonicalFile(); - final File[] directoryList = getCachedDirectoryList(context); + final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context); if (null == directoryList) return; for (File directory : directoryList) { // There is one directory per locale. See #getCachedDirectoryList @@ -318,7 +200,8 @@ final class BinaryDictionaryGetter { final File[] wordLists = directory.listFiles(); if (null == wordLists) continue; for (File wordList : wordLists) { - final String fileId = getWordListIdFromFileName(wordList.getName()); + final String fileId = + DictionaryInfoUtils.getWordListIdFromFileName(wordList.getName()); if (fileId.equals(id)) { if (!canonicalFileToKeep.equals(wordList.getCanonicalFile())) { wordList.delete(); @@ -331,28 +214,6 @@ final class BinaryDictionaryGetter { } } - - /** - * Returns the id associated with the main word list for a specified locale. - * - * Word lists stored in Android Keyboard's resources are referred to as the "main" - * word lists. Since they can be updated like any other list, we need to assign a - * unique ID to them. This ID is just the name of the language (locale-wise) they - * are for, and this method returns this ID. - */ - private static String getMainDictId(final Locale locale) { - // This works because we don't include by default different dictionaries for - // different countries. This actually needs to return the id that we would - // like to use for word lists included in resources, and the following is okay. - return MAIN_DICTIONARY_CATEGORY + ID_CATEGORY_SEPARATOR + locale.getLanguage().toString(); - } - - private static boolean isMainWordListId(final String id) { - final String[] idArray = id.split(ID_CATEGORY_SEPARATOR); - if (2 != idArray.length) return false; - return MAIN_DICTIONARY_CATEGORY.equals(idArray[0]); - } - // ## HACK ## we prevent usage of a dictionary before version 18 for English only. The reason // for this is, since those do not include whitelist entries, the new code with an old version // of the dictionary would lose whitelist functionality. @@ -429,16 +290,16 @@ final class BinaryDictionaryGetter { hasDefaultWordList); } final File[] cachedWordLists = getCachedWordLists(locale.toString(), context); - final String mainDictId = getMainDictId(locale); + final String mainDictId = DictionaryInfoUtils.getMainDictId(locale); final DictPackSettings dictPackSettings = new DictPackSettings(context); boolean foundMainDict = false; final ArrayList<AssetFileAddress> fileList = CollectionUtils.newArrayList(); // cachedWordLists may not be null, see doc for getCachedDictionaryList for (final File f : cachedWordLists) { - final String wordListId = getWordListIdFromFileName(f.getName()); + final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName()); final boolean canUse = f.canRead() && hackCanUseDictionaryFile(locale, f); - if (canUse && isMainWordListId(wordListId)) { + if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) { foundMainDict = true; } if (!dictPackSettings.isWordListActive(wordListId)) continue; @@ -451,7 +312,7 @@ final class BinaryDictionaryGetter { if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) { final int fallbackResId = - DictionaryFactory.getMainDictionaryResourceId(context.getResources(), locale); + DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale); final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId); if (null != fallbackAsset) { fileList.add(fallbackAsset); diff --git a/java/src/com/android/inputmethod/latin/DictionaryFactory.java b/java/src/com/android/inputmethod/latin/DictionaryFactory.java index 388ad6c59..40e51672a 100644 --- a/java/src/com/android/inputmethod/latin/DictionaryFactory.java +++ b/java/src/com/android/inputmethod/latin/DictionaryFactory.java @@ -31,9 +31,6 @@ import java.util.Locale; */ public final class DictionaryFactory { private static final String TAG = DictionaryFactory.class.getSimpleName(); - // This class must be located in the same package as LatinIME.java. - private static final String RESOURCE_PACKAGE_NAME = - DictionaryFactory.class.getPackage().getName(); /** * Initializes a main dictionary collection from a dictionary pack, with explicit flags. @@ -96,8 +93,8 @@ public final class DictionaryFactory { final Locale locale) { AssetFileDescriptor afd = null; try { - final int resId = - getMainDictionaryResourceIdIfAvailableForLocale(context.getResources(), locale); + final int resId = DictionaryInfoUtils.getMainDictionaryResourceIdIfAvailableForLocale( + context.getResources(), locale); if (0 == resId) return null; afd = context.getResources().openRawResourceFd(resId); if (afd == null) { @@ -154,47 +151,7 @@ public final class DictionaryFactory { */ public static boolean isDictionaryAvailable(Context context, Locale locale) { final Resources res = context.getResources(); - return 0 != getMainDictionaryResourceIdIfAvailableForLocale(res, locale); - } - - private static final String DEFAULT_MAIN_DICT = "main"; - private static final String MAIN_DICT_PREFIX = "main_"; - - /** - * Helper method to return a dictionary res id for a locale, or 0 if none. - * @param locale dictionary locale - * @return main dictionary resource id - */ - private static int getMainDictionaryResourceIdIfAvailableForLocale(final Resources res, - final Locale locale) { - int resId; - // Try to find main_language_country dictionary. - if (!locale.getCountry().isEmpty()) { - final String dictLanguageCountry = MAIN_DICT_PREFIX + locale.toString().toLowerCase(); - if ((resId = res.getIdentifier( - dictLanguageCountry, "raw", RESOURCE_PACKAGE_NAME)) != 0) { - return resId; - } - } - - // Try to find main_language dictionary. - final String dictLanguage = MAIN_DICT_PREFIX + locale.getLanguage(); - if ((resId = res.getIdentifier(dictLanguage, "raw", RESOURCE_PACKAGE_NAME)) != 0) { - return resId; - } - - // Not found, return 0 - return 0; - } - - /** - * Returns a main dictionary resource id - * @param locale dictionary locale - * @return main dictionary resource id - */ - public static int getMainDictionaryResourceId(final Resources res, final Locale locale) { - int resourceId = getMainDictionaryResourceIdIfAvailableForLocale(res, locale); - if (0 != resourceId) return resourceId; - return res.getIdentifier(DEFAULT_MAIN_DICT, "raw", RESOURCE_PACKAGE_NAME); + return 0 != DictionaryInfoUtils.getMainDictionaryResourceIdIfAvailableForLocale( + res, locale); } } diff --git a/java/src/com/android/inputmethod/latin/DictionaryInfoUtils.java b/java/src/com/android/inputmethod/latin/DictionaryInfoUtils.java new file mode 100644 index 000000000..c676bf1b9 --- /dev/null +++ b/java/src/com/android/inputmethod/latin/DictionaryInfoUtils.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.latin; + +import android.content.Context; +import android.content.res.Resources; +import android.util.Log; + +import com.android.inputmethod.latin.makedict.BinaryDictIOUtils; +import com.android.inputmethod.latin.makedict.FormatSpec.FileHeader; +import com.android.inputmethod.latin.makedict.UnsupportedFormatException; + +import java.io.File; +import java.io.IOException; +import java.util.Locale; + +/** + * This class encapsulates the logic for the Latin-IME side of dictionary information management. + */ +public class DictionaryInfoUtils { + private static final String TAG = DictionaryInfoUtils.class.getSimpleName(); + // This class must be located in the same package as LatinIME.java. + private static final String RESOURCE_PACKAGE_NAME = + DictionaryInfoUtils.class.getPackage().getName(); + private static final String DEFAULT_MAIN_DICT = "main"; + private static final String MAIN_DICT_PREFIX = "main_"; + // 6 digits - unicode is limited to 21 bits + private static final int MAX_HEX_DIGITS_FOR_CODEPOINT = 6; + + private DictionaryInfoUtils() { + // Private constructor to forbid instantation of this helper class. + } + + /** + * Returns whether we may want to use this character as part of a file name. + * + * This basically only accepts ascii letters and numbers, and rejects everything else. + */ + private static boolean isFileNameCharacter(int codePoint) { + if (codePoint >= 0x30 && codePoint <= 0x39) return true; // Digit + if (codePoint >= 0x41 && codePoint <= 0x5A) return true; // Uppercase + if (codePoint >= 0x61 && codePoint <= 0x7A) return true; // Lowercase + return codePoint == '_'; // Underscore + } + + /** + * Escapes a string for any characters that may be suspicious for a file or directory name. + * + * Concretely this does a sort of URL-encoding except it will encode everything that's not + * alphanumeric or underscore. (true URL-encoding leaves alone characters like '*', which + * we cannot allow here) + */ + // TODO: create a unit test for this method + public static String replaceFileNameDangerousCharacters(final String name) { + // This assumes '%' is fully available as a non-separator, normal + // character in a file name. This is probably true for all file systems. + final StringBuilder sb = new StringBuilder(); + final int nameLength = name.length(); + for (int i = 0; i < nameLength; i = name.offsetByCodePoints(i, 1)) { + final int codePoint = name.codePointAt(i); + if (DictionaryInfoUtils.isFileNameCharacter(codePoint)) { + sb.appendCodePoint(codePoint); + } else { + sb.append(String.format((Locale)null, "%%%1$0" + MAX_HEX_DIGITS_FOR_CODEPOINT + "x", + codePoint)); + } + } + return sb.toString(); + } + + /** + * Helper method to get the top level cache directory. + */ + private static String getWordListCacheDirectory(final Context context) { + return context.getFilesDir() + File.separator + "dicts"; + } + + /** + * Reverse escaping done by replaceFileNameDangerousCharacters. + */ + public static String getWordListIdFromFileName(final String fname) { + final StringBuilder sb = new StringBuilder(); + final int fnameLength = fname.length(); + for (int i = 0; i < fnameLength; i = fname.offsetByCodePoints(i, 1)) { + final int codePoint = fname.codePointAt(i); + if ('%' != codePoint) { + sb.appendCodePoint(codePoint); + } else { + // + 1 to pass the % sign + final int encodedCodePoint = Integer.parseInt( + fname.substring(i + 1, i + 1 + MAX_HEX_DIGITS_FOR_CODEPOINT), 16); + i += MAX_HEX_DIGITS_FOR_CODEPOINT; + sb.appendCodePoint(encodedCodePoint); + } + } + return sb.toString(); + } + + /** + * Helper method to the list of cache directories, one for each distinct locale. + */ + public static File[] getCachedDirectoryList(final Context context) { + return new File(DictionaryInfoUtils.getWordListCacheDirectory(context)).listFiles(); + } + + /** + * Returns the category for a given file name. + * + * This parses the file name, extracts the category, and returns it. See + * {@link #getMainDictId(Locale)} and {@link #isMainWordListId(String)}. + * @return The category as a string or null if it can't be found in the file name. + */ + public static String getCategoryFromFileName(final String fileName) { + final String id = getWordListIdFromFileName(fileName); + final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR); + // An id is supposed to be in format category:locale, so splitting on the separator + // should yield a 2-elements array + if (2 != idArray.length) return null; + return idArray[0]; + } + + /** + * Find out the cache directory associated with a specific locale. + */ + private static String getCacheDirectoryForLocale(final String locale, final Context context) { + final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale); + final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator + + relativeDirectoryName; + final File directory = new File(absoluteDirectoryName); + if (!directory.exists()) { + if (!directory.mkdirs()) { + Log.e(TAG, "Could not create the directory for locale" + locale); + } + } + return absoluteDirectoryName; + } + + /** + * Generates a file name for the id and locale passed as an argument. + * + * In the current implementation the file name returned will always be unique for + * any id/locale pair, but please do not expect that the id can be the same for + * different dictionaries with different locales. An id should be unique for any + * dictionary. + * The file name is pretty much an URL-encoded version of the id inside a directory + * named like the locale, except it will also escape characters that look dangerous + * to some file systems. + * @param id the id of the dictionary for which to get a file name + * @param locale the locale for which to get the file name as a string + * @param context the context to use for getting the directory + * @return the name of the file to be created + */ + public static String getCacheFileName(String id, String locale, Context context) { + final String fileName = replaceFileNameDangerousCharacters(id); + return getCacheDirectoryForLocale(locale, context) + File.separator + fileName; + } + + public static boolean isMainWordListId(final String id) { + final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR); + // An id is supposed to be in format category:locale, so splitting on the separator + // should yield a 2-elements array + if (2 != idArray.length) return false; + return BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY.equals(idArray[0]); + } + + /** + * Helper method to return a dictionary res id for a locale, or 0 if none. + * @param locale dictionary locale + * @return main dictionary resource id + */ + public static int getMainDictionaryResourceIdIfAvailableForLocale(final Resources res, + final Locale locale) { + int resId; + // Try to find main_language_country dictionary. + if (!locale.getCountry().isEmpty()) { + final String dictLanguageCountry = + MAIN_DICT_PREFIX + locale.toString().toLowerCase(Locale.ROOT); + if ((resId = res.getIdentifier( + dictLanguageCountry, "raw", RESOURCE_PACKAGE_NAME)) != 0) { + return resId; + } + } + + // Try to find main_language dictionary. + final String dictLanguage = MAIN_DICT_PREFIX + locale.getLanguage(); + if ((resId = res.getIdentifier(dictLanguage, "raw", RESOURCE_PACKAGE_NAME)) != 0) { + return resId; + } + + // Not found, return 0 + return 0; + } + + /** + * Returns a main dictionary resource id + * @param locale dictionary locale + * @return main dictionary resource id + */ + public static int getMainDictionaryResourceId(final Resources res, final Locale locale) { + int resourceId = getMainDictionaryResourceIdIfAvailableForLocale(res, locale); + if (0 != resourceId) return resourceId; + return res.getIdentifier(DEFAULT_MAIN_DICT, "raw", RESOURCE_PACKAGE_NAME); + } + + /** + * Returns the id associated with the main word list for a specified locale. + * + * Word lists stored in Android Keyboard's resources are referred to as the "main" + * word lists. Since they can be updated like any other list, we need to assign a + * unique ID to them. This ID is just the name of the language (locale-wise) they + * are for, and this method returns this ID. + */ + public static String getMainDictId(final Locale locale) { + // This works because we don't include by default different dictionaries for + // different countries. This actually needs to return the id that we would + // like to use for word lists included in resources, and the following is okay. + return BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY + + BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR + locale.getLanguage().toString(); + } + + public static FileHeader getDictionaryFileHeaderOrNull(final File file) { + try { + final FileHeader header = BinaryDictIOUtils.getDictionaryFileHeader(file); + return header; + } catch (UnsupportedFormatException e) { + return null; + } catch (IOException e) { + return null; + } + } +} diff --git a/java/src/com/android/inputmethod/latin/ExternalDictionaryGetterForDebug.java b/java/src/com/android/inputmethod/latin/ExternalDictionaryGetterForDebug.java index 6e5a37c72..d9e4bb63d 100644 --- a/java/src/com/android/inputmethod/latin/ExternalDictionaryGetterForDebug.java +++ b/java/src/com/android/inputmethod/latin/ExternalDictionaryGetterForDebug.java @@ -21,11 +21,8 @@ import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.os.Environment; -import android.util.Log; -import com.android.inputmethod.latin.makedict.BinaryDictIOUtils; import com.android.inputmethod.latin.makedict.FormatSpec.FileHeader; -import com.android.inputmethod.latin.makedict.UnsupportedFormatException; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; @@ -44,22 +41,11 @@ public class ExternalDictionaryGetterForDebug { + "/Download"; private static final String DICTIONARY_LOCALE_ATTRIBUTE = "locale"; - private static FileHeader getDictionaryFileHeaderOrNull(final File file) { - try { - final FileHeader header = BinaryDictIOUtils.getDictionaryFileHeader(file); - return header; - } catch (UnsupportedFormatException e) { - return null; - } catch (IOException e) { - return null; - } - } - private static String[] findDictionariesInTheDownloadedFolder() { final File[] files = new File(SOURCE_FOLDER).listFiles(); final ArrayList<String> eligibleList = CollectionUtils.newArrayList(); for (File f : files) { - final FileHeader header = getDictionaryFileHeaderOrNull(f); + final FileHeader header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(f); if (null == header) continue; eligibleList.add(f.getName()); } @@ -102,7 +88,7 @@ public class ExternalDictionaryGetterForDebug { private static void askInstallFile(final Context context, final String fileName) { final File file = new File(SOURCE_FOLDER, fileName.toString()); - final FileHeader header = getDictionaryFileHeaderOrNull(file); + final FileHeader header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(file); final StringBuilder message = new StringBuilder(); final String locale = header.mDictionaryOptions.mAttributes.get(DICTIONARY_LOCALE_ATTRIBUTE); @@ -143,7 +129,7 @@ public class ExternalDictionaryGetterForDebug { final String id = BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY + BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR + locale; final String finalFileName = - BinaryDictionaryGetter.getCacheFileName(id, locale, context); + DictionaryInfoUtils.getCacheFileName(id, locale, context); final String tempFileName = BinaryDictionaryGetter.getTempFileName(id, context); tempFile = new File(tempFileName); tempFile.delete(); diff --git a/java/src/com/android/inputmethod/latin/LatinIME.java b/java/src/com/android/inputmethod/latin/LatinIME.java index d6487cb0c..08217326a 100644 --- a/java/src/com/android/inputmethod/latin/LatinIME.java +++ b/java/src/com/android/inputmethod/latin/LatinIME.java @@ -428,7 +428,7 @@ public final class LatinIME extends InputMethodService implements KeyboardAction initSuggest(); if (ProductionFlag.IS_EXPERIMENTAL) { - ResearchLogger.getInstance().init(this); + ResearchLogger.getInstance().init(this, mKeyboardSwitcher); } mDisplayOrientation = getResources().getConfiguration().orientation; diff --git a/java/src/com/android/inputmethod/research/FeedbackActivity.java b/java/src/com/android/inputmethod/research/FeedbackActivity.java index f66d55bdd..b985fda21 100644 --- a/java/src/com/android/inputmethod/research/FeedbackActivity.java +++ b/java/src/com/android/inputmethod/research/FeedbackActivity.java @@ -28,25 +28,10 @@ public class FeedbackActivity extends Activity { super.onCreate(savedInstanceState); setContentView(R.layout.research_feedback_activity); final FeedbackLayout layout = (FeedbackLayout) findViewById(R.id.research_feedback_layout); - final CheckBox checkbox = (CheckBox) findViewById(R.id.research_feedback_include_history); - final CharSequence cs = checkbox.getText(); - final String actualString = String.format(cs.toString(), - ResearchLogger.FEEDBACK_WORD_BUFFER_SIZE); - checkbox.setText(actualString); layout.setActivity(this); } @Override - protected void onResume() { - super.onResume(); - } - - @Override - protected void onPause() { - super.onPause(); - } - - @Override public void onBackPressed() { ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); super.onBackPressed(); diff --git a/java/src/com/android/inputmethod/research/FeedbackFragment.java b/java/src/com/android/inputmethod/research/FeedbackFragment.java index fee61a923..11a833a85 100644 --- a/java/src/com/android/inputmethod/research/FeedbackFragment.java +++ b/java/src/com/android/inputmethod/research/FeedbackFragment.java @@ -20,6 +20,7 @@ import android.app.Activity; import android.app.Fragment; import android.os.Bundle; import android.text.Editable; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; @@ -30,10 +31,18 @@ import android.widget.EditText; import com.android.inputmethod.latin.R; -public class FeedbackFragment extends Fragment { +public class FeedbackFragment extends Fragment implements OnClickListener { + private static final String TAG = FeedbackFragment.class.getSimpleName(); + + private static final String KEY_FEEDBACK_STRING = "FeedbackString"; + private static final String KEY_INCLUDE_ACCOUNT_NAME = "IncludeAccountName"; + public static final String KEY_HAS_USER_RECORDING = "HasRecording"; + private EditText mEditText; - private CheckBox mIncludingHistoryCheckBox; private CheckBox mIncludingAccountNameCheckBox; + private CheckBox mIncludingUserRecordingCheckBox; + private Button mSendButton; + private Button mCancelButton; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -41,39 +50,96 @@ public class FeedbackFragment extends Fragment { final View view = inflater.inflate(R.layout.research_feedback_fragment_layout, container, false); mEditText = (EditText) view.findViewById(R.id.research_feedback_contents); - mIncludingHistoryCheckBox = (CheckBox) view.findViewById( - R.id.research_feedback_include_history); + mEditText.requestFocus(); mIncludingAccountNameCheckBox = (CheckBox) view.findViewById( R.id.research_feedback_include_account_name); + mIncludingUserRecordingCheckBox = (CheckBox) view.findViewById( + R.id.research_feedback_include_recording_checkbox); + mIncludingUserRecordingCheckBox.setOnClickListener(this); + + mSendButton = (Button) view.findViewById(R.id.research_feedback_send_button); + mSendButton.setOnClickListener(this); + mCancelButton = (Button) view.findViewById(R.id.research_feedback_cancel_button); + mCancelButton.setOnClickListener(this); - final Button sendButton = (Button) view.findViewById( - R.id.research_feedback_send_button); - sendButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - final Editable editable = mEditText.getText(); - final String feedbackContents = editable.toString(); - final boolean isIncludingHistory = mIncludingHistoryCheckBox.isChecked(); - final boolean isIncludingAccountName = mIncludingAccountNameCheckBox.isChecked(); - ResearchLogger.getInstance().sendFeedback(feedbackContents, isIncludingHistory, - isIncludingAccountName); - final Activity activity = FeedbackFragment.this.getActivity(); - activity.finish(); - ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); + if (savedInstanceState != null) { + Log.d(TAG, "restoring from savedInstanceState"); + restoreState(savedInstanceState); + } else { + final Bundle bundle = getActivity().getIntent().getExtras(); + if (bundle != null) { + Log.d(TAG, "restoring from getArguments()"); + restoreState(bundle); } - }); - - final Button cancelButton = (Button) view.findViewById( - R.id.research_feedback_cancel_button); - cancelButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - final Activity activity = FeedbackFragment.this.getActivity(); - activity.finish(); - ResearchLogger.getInstance().onLeavingSendFeedbackDialog(); + } + return view; + } + + @Override + public void onClick(final View view) { + final ResearchLogger researchLogger = ResearchLogger.getInstance(); + if (view == mIncludingUserRecordingCheckBox) { + if (hasUserRecording()) { + // Remove the recording + setHasUserRecording(false); + } else { + final Bundle bundle = new Bundle(); + onSaveInstanceState(bundle); + + // Let the user make a recording + getActivity().finish(); + + researchLogger.setFeedbackDialogBundle(bundle); + researchLogger.onLeavingSendFeedbackDialog(); + researchLogger.startRecording(); } - }); + } else if (view == mSendButton) { + final Editable editable = mEditText.getText(); + final String feedbackContents = editable.toString(); + final boolean isIncludingAccountName = isIncludingAccountName(); + researchLogger.sendFeedback(feedbackContents, + false /* isIncludingHistory */, isIncludingAccountName, hasUserRecording()); + getActivity().finish(); + researchLogger.setFeedbackDialogBundle(null); + researchLogger.onLeavingSendFeedbackDialog(); + } else if (view == mCancelButton) { + Log.d(TAG, "Finishing"); + getActivity().finish(); + researchLogger.setFeedbackDialogBundle(null); + researchLogger.onLeavingSendFeedbackDialog(); + } else { + Log.e(TAG, "Unknown view passed to FeedbackFragment.onClick()"); + } + } - return view; + @Override + public void onSaveInstanceState(final Bundle bundle) { + final String savedFeedbackString = mEditText.getText().toString(); + + bundle.putString(KEY_FEEDBACK_STRING, savedFeedbackString); + bundle.putBoolean(KEY_INCLUDE_ACCOUNT_NAME, isIncludingAccountName()); + bundle.putBoolean(KEY_HAS_USER_RECORDING, hasUserRecording()); + } + + public void restoreState(final Bundle bundle) { + mEditText.setText(bundle.getString(KEY_FEEDBACK_STRING)); + setIsIncludingAccountName(bundle.getBoolean(KEY_INCLUDE_ACCOUNT_NAME)); + setHasUserRecording(bundle.getBoolean(KEY_HAS_USER_RECORDING)); + } + + private boolean hasUserRecording() { + return mIncludingUserRecordingCheckBox.isChecked(); + } + + private void setHasUserRecording(final boolean hasRecording) { + mIncludingUserRecordingCheckBox.setChecked(hasRecording); + } + + private boolean isIncludingAccountName() { + return mIncludingAccountNameCheckBox.isChecked(); + } + + private void setIsIncludingAccountName(final boolean isIncludingAccountName) { + mIncludingAccountNameCheckBox.setChecked(isIncludingAccountName); } } diff --git a/java/src/com/android/inputmethod/research/LogStatement.java b/java/src/com/android/inputmethod/research/LogStatement.java new file mode 100644 index 000000000..090c58e27 --- /dev/null +++ b/java/src/com/android/inputmethod/research/LogStatement.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.inputmethod.research; + +/** + * A template for typed information stored in the logs. + * + * A LogStatement contains a name, keys, and flags about whether the {@code Object[] values} + * associated with the {@code String[] keys} are likely to reveal information about the user. The + * actual values are stored separately. + */ +class LogStatement { + // Constants for particular statements + public static final String TYPE_POINTER_TRACKER_CALL_LISTENER_ON_CODE_INPUT = + "PointerTrackerCallListenerOnCodeInput"; + public static final String KEY_CODE = "code"; + public static final String VALUE_RESEARCH = "research"; + public static final String TYPE_LATIN_KEYBOARD_VIEW_ON_LONG_PRESS = + "LatinKeyboardViewOnLongPress"; + public static final String ACTION = "action"; + public static final String VALUE_DOWN = "DOWN"; + public static final String TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS = + "LatinKeyboardViewProcessMotionEvents"; + public static final String KEY_LOGGING_RELATED = "loggingRelated"; + + // Name specifying the LogStatement type. + private final String mType; + + // mIsPotentiallyPrivate indicates that event contains potentially private information. If + // the word that this event is a part of is determined to be privacy-sensitive, then this + // event should not be included in the output log. The system waits to output until the + // containing word is known. + private final boolean mIsPotentiallyPrivate; + + // mIsPotentiallyRevealing indicates that this statement may disclose details about other + // words typed in other LogUnits. This can happen if the user is not inserting spaces, and + // data from Suggestions and/or Composing text reveals the entire "megaword". For example, + // say the user is typing "for the win", and the system wants to record the bigram "the + // win". If the user types "forthe", omitting the space, the system will give "for the" as + // a suggestion. If the user accepts the autocorrection, the suggestion for "for the" is + // included in the log for the word "the", disclosing that the previous word had been "for". + // For now, we simply do not include this data when logging part of a "megaword". + private final boolean mIsPotentiallyRevealing; + + // mKeys stores the names that are the attributes in the output json objects + private final String[] mKeys; + private static final String[] NULL_KEYS = new String[0]; + + LogStatement(final String name, final boolean isPotentiallyPrivate, + final boolean isPotentiallyRevealing, final String... keys) { + mType = name; + mIsPotentiallyPrivate = isPotentiallyPrivate; + mIsPotentiallyRevealing = isPotentiallyRevealing; + mKeys = (keys == null) ? NULL_KEYS : keys; + } + + public String getType() { + return mType; + } + + public boolean isPotentiallyPrivate() { + return mIsPotentiallyPrivate; + } + + public boolean isPotentiallyRevealing() { + return mIsPotentiallyRevealing; + } + + public String[] getKeys() { + return mKeys; + } + + /** + * Utility function to test whether a key-value pair exists in a LogStatement. + * + * A LogStatement is really just a template -- it does not contain the values, only the + * keys. So the values must be passed in as an argument. + * + * @param queryKey the String that is tested by {@code String.equals()} to the keys in the + * LogStatement + * @param queryValue an Object that must be {@code Object.equals()} to the key's corresponding + * value in the {@code values} array + * @param values the values corresponding to mKeys + * + * @returns {@true} if {@code queryKey} exists in the keys for this LogStatement, and {@code + * queryValue} matches the corresponding value in {@code values} + * + * @throws IllegalArgumentException if {@code values.length} is not equal to keys().length() + */ + public boolean containsKeyValuePair(final String queryKey, final Object queryValue, + final Object[] values) { + if (mKeys.length != values.length) { + throw new IllegalArgumentException("Mismatched number of keys and values."); + } + final int length = mKeys.length; + for (int i = 0; i < length; i++) { + if (mKeys[i].equals(queryKey) && values[i].equals(queryValue)) { + return true; + } + } + return false; + } + + /** + * Utility function to set a value in a LogStatement. + * + * A LogStatement is really just a template -- it does not contain the values, only the + * keys. So the values must be passed in as an argument. + * + * @param queryKey the String that is tested by {@code String.equals()} to the keys in the + * LogStatement + * @param values the array of values corresponding to mKeys + * @param newValue the replacement value to go into the {@code values} array + * + * @returns {@true} if the key exists and the value was successfully set, {@false} otherwise + * + * @throws IllegalArgumentException if {@code values.length} is not equal to keys().length() + */ + public boolean setValue(final String queryKey, final Object[] values, final Object newValue) { + if (mKeys.length != values.length) { + throw new IllegalArgumentException("Mismatched number of keys and values."); + } + final int length = mKeys.length; + for (int i = 0; i < length; i++) { + if (mKeys[i].equals(queryKey)) { + values[i] = newValue; + return true; + } + } + return false; + } +} diff --git a/java/src/com/android/inputmethod/research/LogUnit.java b/java/src/com/android/inputmethod/research/LogUnit.java index 638b7d9d4..608fab3f1 100644 --- a/java/src/com/android/inputmethod/research/LogUnit.java +++ b/java/src/com/android/inputmethod/research/LogUnit.java @@ -26,15 +26,12 @@ import android.view.inputmethod.CompletionInfo; import com.android.inputmethod.keyboard.Key; import com.android.inputmethod.latin.SuggestedWords; import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo; -import com.android.inputmethod.latin.Utils; import com.android.inputmethod.latin.define.ProductionFlag; -import com.android.inputmethod.research.ResearchLogger.LogStatement; import java.io.IOException; import java.io.StringWriter; import java.util.ArrayList; import java.util.List; -import java.util.Map; /** * A group of log statements related to each other. @@ -53,6 +50,7 @@ import java.util.Map; /* package */ class LogUnit { private static final String TAG = LogUnit.class.getSimpleName(); private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + private final ArrayList<LogStatement> mLogStatementList; private final ArrayList<Object[]> mValuesList; // Assume that mTimeList is sorted in increasing order. Do not insert null values into @@ -142,10 +140,10 @@ import java.util.Map; JsonWriter jsonWriter = null; for (int i = 0; i < size; i++) { final LogStatement logStatement = mLogStatementList.get(i); - if (!canIncludePrivateData && logStatement.mIsPotentiallyPrivate) { + if (!canIncludePrivateData && logStatement.isPotentiallyPrivate()) { continue; } - if (mIsPartOfMegaword && logStatement.mIsPotentiallyRevealing) { + if (mIsPartOfMegaword && logStatement.isPotentiallyRevealing()) { continue; } // Only retrieve the jsonWriter if we need to. If we don't get this far, then @@ -228,16 +226,16 @@ import java.util.Map; private boolean outputLogStatementToLocked(final JsonWriter jsonWriter, final LogStatement logStatement, final Object[] values, final Long time) { if (DEBUG) { - if (logStatement.mKeys.length != values.length) { - Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.mName); + if (logStatement.getKeys().length != values.length) { + Log.d(TAG, "Key and Value list sizes do not match. " + logStatement.getType()); } } try { jsonWriter.beginObject(); jsonWriter.name(CURRENT_TIME_KEY).value(System.currentTimeMillis()); jsonWriter.name(UPTIME_KEY).value(time); - jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.mName); - final String[] keys = logStatement.mKeys; + jsonWriter.name(EVENT_TYPE_KEY).value(logStatement.getType()); + final String[] keys = logStatement.getKeys(); final int length = values.length; for (int i = 0; i < length; i++) { jsonWriter.name(keys[i]); @@ -261,8 +259,8 @@ import java.util.Map; } else if (value == null) { jsonWriter.nullValue(); } else { - Log.w(TAG, "Unrecognized type to be logged: " + - (value == null ? "<null>" : value.getClass().getName())); + Log.w(TAG, "Unrecognized type to be logged: " + + (value == null ? "<null>" : value.getClass().getName())); jsonWriter.nullValue(); } } @@ -422,4 +420,123 @@ import java.util.Map; } return false; } + + /** + * Remove data associated with selecting the Research button. + * + * A LogUnit will capture all user interactions with the IME, including the "meta-interactions" + * of using the Research button to control the logging (e.g. by starting and stopping recording + * of a test case). Because meta-interactions should not be part of the normal log, calling + * this method will set a field in the LogStatements of the motion events to indiciate that + * they should be disregarded. + * + * This implementation assumes that the data recorded by the meta-interaction takes the + * form of all events following the first MotionEvent.ACTION_DOWN before the first long-press + * before the last onCodeEvent containing a code matching {@code LogStatement.VALUE_RESEARCH}. + * + * @returns true if data was removed + */ + public boolean removeResearchButtonInvocation() { + // This method is designed to be idempotent. + + // First, find last invocation of "research" key + final int indexOfLastResearchKey = findLastIndexContainingKeyValue( + LogStatement.TYPE_POINTER_TRACKER_CALL_LISTENER_ON_CODE_INPUT, + LogStatement.KEY_CODE, LogStatement.VALUE_RESEARCH); + if (indexOfLastResearchKey < 0) { + // Could not find invocation of "research" key. Leave log as is. + if (DEBUG) { + Log.d(TAG, "Could not find research key"); + } + return false; + } + + // Look for the long press that started the invocation of the research key code input. + final int indexOfLastLongPressBeforeResearchKey = + findLastIndexBefore(LogStatement.TYPE_LATIN_KEYBOARD_VIEW_ON_LONG_PRESS, + indexOfLastResearchKey); + + // Look for DOWN event preceding the long press + final int indexOfLastDownEventBeforeLongPress = + findLastIndexContainingKeyValueBefore( + LogStatement.TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS, + LogStatement.ACTION, LogStatement.VALUE_DOWN, + indexOfLastLongPressBeforeResearchKey); + + // Flag all LatinKeyboardViewProcessMotionEvents from the DOWN event to the research key as + // logging-related + final int startingIndex = indexOfLastDownEventBeforeLongPress == -1 ? 0 + : indexOfLastDownEventBeforeLongPress; + for (int index = startingIndex; index < indexOfLastResearchKey; index++) { + final LogStatement logStatement = mLogStatementList.get(index); + final String type = logStatement.getType(); + final Object[] values = mValuesList.get(index); + if (type.equals(LogStatement.TYPE_LATIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENTS)) { + logStatement.setValue(LogStatement.KEY_LOGGING_RELATED, values, true); + } + } + return true; + } + + /** + * Find the index of the last LogStatement before {@code startingIndex} of type {@code type}. + * + * @param queryType a String that must be {@code String.equals()} to the LogStatement type + * @param startingIndex the index to start the backward search from. Must be less than the + * length of mLogStatementList, or an IndexOutOfBoundsException is thrown. Can be negative, + * in which case -1 is returned. + * + * @return The index of the last LogStatement, -1 if none exists. + */ + private int findLastIndexBefore(final String queryType, final int startingIndex) { + return findLastIndexContainingKeyValueBefore(queryType, null, null, startingIndex); + } + + /** + * Find the index of the last LogStatement before {@code startingIndex} of type {@code type} + * containing the given key-value pair. + * + * @param queryType a String that must be {@code String.equals()} to the LogStatement type + * @param queryKey a String that must be {@code String.equals()} to a key in the LogStatement + * @param queryValue an Object that must be {@code String.equals()} to the key's corresponding + * value + * + * @return The index of the last LogStatement, -1 if none exists. + */ + private int findLastIndexContainingKeyValue(final String queryType, final String queryKey, + final Object queryValue) { + return findLastIndexContainingKeyValueBefore(queryType, queryKey, queryValue, + mLogStatementList.size() - 1); + } + + /** + * Find the index of the last LogStatement before {@code startingIndex} of type {@code type} + * containing the given key-value pair. + * + * @param queryType a String that must be {@code String.equals()} to the LogStatement type + * @param queryKey a String that must be {@code String.equals()} to a key in the LogStatement + * @param queryValue an Object that must be {@code String.equals()} to the key's corresponding + * value + * @param startingIndex the index to start the backward search from. Must be less than the + * length of mLogStatementList, or an IndexOutOfBoundsException is thrown. Can be negative, + * in which case -1 is returned. + * + * @return The index of the last LogStatement, -1 if none exists. + */ + private int findLastIndexContainingKeyValueBefore(final String queryType, final String queryKey, + final Object queryValue, final int startingIndex) { + if (startingIndex < 0) { + return -1; + } + for (int index = startingIndex; index >= 0; index--) { + final LogStatement logStatement = mLogStatementList.get(index); + final String type = logStatement.getType(); + if (type.equals(queryType) && (queryKey == null + || logStatement.containsKeyValuePair(queryKey, queryValue, + mValuesList.get(index)))) { + return index; + } + } + return -1; + } } diff --git a/java/src/com/android/inputmethod/research/MotionEventReader.java b/java/src/com/android/inputmethod/research/MotionEventReader.java new file mode 100644 index 000000000..36e75be1c --- /dev/null +++ b/java/src/com/android/inputmethod/research/MotionEventReader.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.android.inputmethod.research; + +import android.util.JsonReader; +import android.util.Log; +import android.view.MotionEvent; + +import com.android.inputmethod.latin.define.ProductionFlag; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; + +public class MotionEventReader { + private static final String TAG = MotionEventReader.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + + public ReplayData readMotionEventData(final File file) { + final ReplayData replayData = new ReplayData(); + try { + // Read file + final JsonReader jsonReader = new JsonReader(new BufferedReader(new InputStreamReader( + new FileInputStream(file)))); + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + readLogStatement(jsonReader, replayData); + } + jsonReader.endArray(); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + return replayData; + } + + static class ReplayData { + final ArrayList<Integer> mActions = new ArrayList<Integer>(); + final ArrayList<Integer> mXCoords = new ArrayList<Integer>(); + final ArrayList<Integer> mYCoords = new ArrayList<Integer>(); + final ArrayList<Long> mTimes = new ArrayList<Long>(); + } + + private void readLogStatement(final JsonReader jsonReader, final ReplayData replayData) + throws IOException { + String logStatementType = null; + Integer actionType = null; + Integer x = null; + Integer y = null; + Long time = null; + boolean loggingRelated = false; + + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + final String key = jsonReader.nextName(); + if (key.equals("_ty")) { + logStatementType = jsonReader.nextString(); + } else if (key.equals("_ut")) { + time = jsonReader.nextLong(); + } else if (key.equals("x")) { + x = jsonReader.nextInt(); + } else if (key.equals("y")) { + y = jsonReader.nextInt(); + } else if (key.equals("action")) { + final String s = jsonReader.nextString(); + if (s.equals("UP")) { + actionType = MotionEvent.ACTION_UP; + } else if (s.equals("DOWN")) { + actionType = MotionEvent.ACTION_DOWN; + } else if (s.equals("MOVE")) { + actionType = MotionEvent.ACTION_MOVE; + } + } else if (key.equals("loggingRelated")) { + loggingRelated = jsonReader.nextBoolean(); + } else { + if (DEBUG) { + Log.w(TAG, "Unknown JSON key in LogStatement: " + key); + } + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + + if (logStatementType != null && time != null && x != null && y != null && actionType != null + && logStatementType.equals("MainKeyboardViewProcessMotionEvent") + && !loggingRelated) { + replayData.mActions.add(actionType); + replayData.mXCoords.add(x); + replayData.mYCoords.add(y); + replayData.mTimes.add(time); + } + } + +} diff --git a/java/src/com/android/inputmethod/research/Replayer.java b/java/src/com/android/inputmethod/research/Replayer.java new file mode 100644 index 000000000..4cc2a5814 --- /dev/null +++ b/java/src/com/android/inputmethod/research/Replayer.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.android.inputmethod.research; + +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; + +import com.android.inputmethod.keyboard.KeyboardSwitcher; +import com.android.inputmethod.keyboard.MainKeyboardView; +import com.android.inputmethod.latin.define.ProductionFlag; +import com.android.inputmethod.research.MotionEventReader.ReplayData; + +/** + * Replays a sequence of motion events in realtime on the screen. + * + * Useful for user inspection of logged data. + */ +public class Replayer { + private static final String TAG = Replayer.class.getSimpleName(); + private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + private static final long START_TIME_DELAY_MS = 500; + + private boolean mIsReplaying = false; + private KeyboardSwitcher mKeyboardSwitcher; + + public void setKeyboardSwitcher(final KeyboardSwitcher keyboardSwitcher) { + mKeyboardSwitcher = keyboardSwitcher; + } + + private static final int MSG_MOTION_EVENT = 0; + private static final int MSG_DONE = 1; + private static final int COMPLETION_TIME_MS = 500; + + // TODO: Support historical events and multi-touch. + public void replay(final ReplayData replayData) { + if (mIsReplaying) { + return; + } + + mIsReplaying = true; + final int numActions = replayData.mActions.size(); + if (DEBUG) { + Log.d(TAG, "replaying " + numActions + " actions"); + } + if (numActions == 0) { + mIsReplaying = false; + return; + } + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + + // The reference time relative to the times stored in events. + final long origStartTime = replayData.mTimes.get(0); + // The reference time relative to which events are replayed in the present. + final long currentStartTime = SystemClock.uptimeMillis() + START_TIME_DELAY_MS; + // The adjustment needed to translate times from the original recorded time to the current + // time. + final long timeAdjustment = currentStartTime - origStartTime; + final Handler handler = new Handler() { + // Track the time of the most recent DOWN event, to be passed as a parameter when + // constructing a MotionEvent. It's initialized here to the origStartTime, but this is + // only a precaution. The value should be overwritten by the first ACTION_DOWN event + // before the first use of the variable. Note that this may cause the first few events + // to have incorrect {@code downTime}s. + private long mOrigDownTime = origStartTime; + + @Override + public void handleMessage(final Message msg) { + switch (msg.what) { + case MSG_MOTION_EVENT: + final int index = msg.arg1; + final int action = replayData.mActions.get(index); + final int x = replayData.mXCoords.get(index); + final int y = replayData.mYCoords.get(index); + final long origTime = replayData.mTimes.get(index); + if (action == MotionEvent.ACTION_DOWN) { + mOrigDownTime = origTime; + } + + final MotionEvent me = MotionEvent.obtain(mOrigDownTime + timeAdjustment, + origTime + timeAdjustment, action, x, y, 0); + mainKeyboardView.processMotionEvent(me); + me.recycle(); + break; + case MSG_DONE: + mIsReplaying = false; + break; + } + } + }; + + for (int i = 0; i < numActions; i++) { + final Message msg = Message.obtain(handler, MSG_MOTION_EVENT, i, 0); + final long msgTime = replayData.mTimes.get(i) + timeAdjustment; + handler.sendMessageAtTime(msg, msgTime); + if (DEBUG) { + Log.d(TAG, "queuing event at " + msgTime); + } + } + final long presentDoneTime = replayData.mTimes.get(numActions - 1) + timeAdjustment + + COMPLETION_TIME_MS; + handler.sendMessageAtTime(Message.obtain(handler, MSG_DONE), presentDoneTime); + } +} diff --git a/java/src/com/android/inputmethod/research/ResearchLogger.java b/java/src/com/android/inputmethod/research/ResearchLogger.java index dbf2d2982..c4d53e10a 100644 --- a/java/src/com/android/inputmethod/research/ResearchLogger.java +++ b/java/src/com/android/inputmethod/research/ResearchLogger.java @@ -39,6 +39,8 @@ import android.graphics.Paint; import android.graphics.Paint.Style; import android.net.Uri; import android.os.Build; +import android.os.Bundle; +import android.os.Handler; import android.os.IBinder; import android.os.SystemClock; import android.preference.PreferenceManager; @@ -57,6 +59,7 @@ import android.widget.Toast; import com.android.inputmethod.keyboard.Key; import com.android.inputmethod.keyboard.Keyboard; import com.android.inputmethod.keyboard.KeyboardId; +import com.android.inputmethod.keyboard.KeyboardSwitcher; import com.android.inputmethod.keyboard.KeyboardView; import com.android.inputmethod.keyboard.MainKeyboardView; import com.android.inputmethod.latin.Constants; @@ -69,8 +72,17 @@ import com.android.inputmethod.latin.RichInputConnection.Range; import com.android.inputmethod.latin.Suggest; import com.android.inputmethod.latin.SuggestedWords; import com.android.inputmethod.latin.define.ProductionFlag; +import com.android.inputmethod.research.MotionEventReader.ReplayData; +import java.io.BufferedReader; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -87,8 +99,18 @@ import java.util.UUID; * This functionality is off by default. See {@link ProductionFlag#IS_EXPERIMENTAL}. */ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChangeListener { + // TODO: This class has grown quite large and combines several concerns that should be + // separated. The following refactorings will be applied as soon as possible after adding + // support for replaying historical events, fixing some replay bugs, adding some ui constraints + // on the feedback dialog, and adding the survey dialog. + // TODO: Refactor. Move splash screen code into separate class. + // TODO: Refactor. Move feedback screen code into separate class. + // TODO: Refactor. Move logging invocations into their own class. + // TODO: Refactor. Move currentLogUnit management into separate class. private static final String TAG = ResearchLogger.class.getSimpleName(); private static final boolean DEBUG = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; + private static final boolean DEBUG_REPLAY_AFTER_FEEDBACK = false + && ProductionFlag.IS_EXPERIMENTAL_DEBUG; // Whether the TextView contents are logged at the end of the session. true will disclose // private info. private static final boolean LOG_FULL_TEXTVIEW_CONTENTS = false @@ -98,8 +120,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang private static final int OUTPUT_FORMAT_VERSION = 5; private static final String PREF_USABILITY_STUDY_MODE = "usability_study_mode"; private static final String PREF_RESEARCH_HAS_SEEN_SPLASH = "pref_research_has_seen_splash"; - /* package */ static final String FILENAME_PREFIX = "researchLog"; - private static final String FILENAME_SUFFIX = ".txt"; + /* package */ static final String LOG_FILENAME_PREFIX = "researchLog"; + private static final String LOG_FILENAME_SUFFIX = ".txt"; + /* package */ static final String USER_RECORDING_FILENAME_PREFIX = "recording"; + private static final String USER_RECORDING_FILENAME_SUFFIX = ".txt"; private static final SimpleDateFormat TIMESTAMP_DATEFORMAT = new SimpleDateFormat("yyyyMMddHHmmssS", Locale.US); // Whether to show an indicator on the screen that logging is on. Currently a very small red @@ -129,9 +153,15 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang // the system to do so. // LogUnits are queued in the LogBuffers and published to the ResearchLogs when words are // complete. - /* package */ ResearchLog mFeedbackLog; /* package */ MainLogBuffer mMainLogBuffer; + // TODO: Remove the feedback log. The feedback log continuously captured user data in case the + // user wanted to submit it. We now use the mUserRecordingLogBuffer to allow the user to + // explicitly reproduce a problem. + /* package */ ResearchLog mFeedbackLog; /* package */ LogBuffer mFeedbackLogBuffer; + /* package */ ResearchLog mUserRecordingLog; + /* package */ LogBuffer mUserRecordingLogBuffer; + private File mUserRecordingFile = null; private boolean mIsPasswordView = false; private boolean mIsLoggingSuspended = false; @@ -144,7 +174,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang /* package for test */ static final String WORD_REPLACEMENT_STRING = "\uE001"; private static final String PREF_LAST_CLEANUP_TIME = "pref_last_cleanup_time"; private static final long DURATION_BETWEEN_DIR_CLEANUP_IN_MS = DateUtils.DAY_IN_MILLIS; - private static final long MAX_LOGFILE_AGE_IN_MS = DateUtils.DAY_IN_MILLIS; + private static final long MAX_LOGFILE_AGE_IN_MS = 4 * DateUtils.DAY_IN_MILLIS; protected static final int SUSPEND_DURATION_IN_MINUTES = 1; // set when LatinIME should ignore an onUpdateSelection() callback that // arises from operations in this class @@ -153,10 +183,14 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang // used to check whether words are not unique private Suggest mSuggest; private MainKeyboardView mMainKeyboardView; + // TODO: Check whether a superclass can be used instead of LatinIME. private LatinIME mLatinIME; private final Statistics mStatistics; + private final MotionEventReader mMotionEventReader = new MotionEventReader(); + private final Replayer mReplayer = new Replayer(); private Intent mUploadIntent; + private Intent mUploadNowIntent; private LogUnit mCurrentLogUnit = new LogUnit(); @@ -165,6 +199,20 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang // thereby leaking private data, we store the time of the down event that started the second // gesture, and when committing the earlier word, split the LogUnit. private long mSavedDownEventTime; + private Bundle mFeedbackDialogBundle = null; + private boolean mInFeedbackDialog = false; + // The feedback dialog causes stop() to be called for the keyboard connected to the original + // window. This is because the feedback dialog must present its own EditText box that displays + // a keyboard. stop() normally causes mFeedbackLogBuffer, which contains the user's data, to be + // cleared, and causes mFeedbackLog, which is ready to collect information in case the user + // wants to upload, to be closed. This is good because we don't need to log information about + // what the user is typing in the feedback dialog, but bad because this data must be uploaded. + // Here we save the LogBuffer and Log so the feedback dialog can later access their data. + private LogBuffer mSavedFeedbackLogBuffer; + private ResearchLog mSavedFeedbackLog; + private Handler mUserRecordingTimeoutHandler; + private static final long USER_RECORDING_TIMEOUT_MS = 30L * DateUtils.SECOND_IN_MILLIS; + private ResearchLogger() { mStatistics = Statistics.getInstance(); } @@ -173,7 +221,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang return sInstance; } - public void init(final LatinIME latinIME) { + public void init(final LatinIME latinIME, final KeyboardSwitcher keyboardSwitcher) { assert latinIME != null; if (latinIME == null) { Log.w(TAG, "IMS is null; logging is off"); @@ -210,6 +258,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang mLatinIME = latinIME; mPrefs = prefs; mUploadIntent = new Intent(mLatinIME, UploaderService.class); + mUploadNowIntent = new Intent(mLatinIME, UploaderService.class); + mUploadNowIntent.putExtra(UploaderService.EXTRA_UPLOAD_UNCONDITIONALLY, true); + mReplayer.setKeyboardSwitcher(keyboardSwitcher); if (ProductionFlag.IS_EXPERIMENTAL) { scheduleUploadingService(mLatinIME); @@ -237,8 +288,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang private void cleanupLoggingDir(final File dir, final long time) { for (File file : dir.listFiles()) { - if (file.getName().startsWith(ResearchLogger.FILENAME_PREFIX) && - file.lastModified() < time) { + final String filename = file.getName(); + if ((filename.startsWith(ResearchLogger.LOG_FILENAME_PREFIX) + || filename.startsWith(ResearchLogger.USER_RECORDING_FILENAME_PREFIX)) + && file.lastModified() < time) { file.delete(); } } @@ -335,9 +388,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang private static int sLogFileCounter = 0; - private File createLogFile(File filesDir) { + private File createLogFile(final File filesDir) { final StringBuilder sb = new StringBuilder(); - sb.append(FILENAME_PREFIX).append('-'); + sb.append(LOG_FILENAME_PREFIX).append('-'); sb.append(mUUIDString).append('-'); sb.append(TIMESTAMP_DATEFORMAT.format(new Date())).append('-'); // Sometimes logFiles are created within milliseconds of each other. Append a counter to @@ -349,7 +402,16 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang sLogFileCounter = 0; } sb.append(sLogFileCounter); - sb.append(FILENAME_SUFFIX); + sb.append(LOG_FILENAME_SUFFIX); + return new File(filesDir, sb.toString()); + } + + private File createUserRecordingFile(final File filesDir) { + final StringBuilder sb = new StringBuilder(); + sb.append(USER_RECORDING_FILENAME_PREFIX).append('-'); + sb.append(mUUIDString).append('-'); + sb.append(TIMESTAMP_DATEFORMAT.format(new Date())); + sb.append(USER_RECORDING_FILENAME_SUFFIX); return new File(filesDir, sb.toString()); } @@ -517,51 +579,10 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang presentFeedbackDialog(latinIME); } - // TODO: currently unreachable. Remove after being sure no menu is needed. - /* - public void presentResearchDialog(final LatinIME latinIME) { - final CharSequence title = latinIME.getString(R.string.english_ime_research_log); - final boolean showEnable = mIsLoggingSuspended || !sIsLogging; - final CharSequence[] items = new CharSequence[] { - latinIME.getString(R.string.research_feedback_menu_option), - showEnable ? latinIME.getString(R.string.research_enable_session_logging) : - latinIME.getString(R.string.research_do_not_log_this_session) - }; - final DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface di, int position) { - di.dismiss(); - switch (position) { - case 0: - presentFeedbackDialog(latinIME); - break; - case 1: - enableOrDisable(showEnable, latinIME); - break; - } - } - - }; - final AlertDialog.Builder builder = new AlertDialog.Builder(latinIME) - .setItems(items, listener) - .setTitle(title); - latinIME.showOptionDialog(builder.create()); - } - */ - - private boolean mInFeedbackDialog = false; - - // The feedback dialog causes stop() to be called for the keyboard connected to the original - // window. This is because the feedback dialog must present its own EditText box that displays - // a keyboard. stop() normally causes mFeedbackLogBuffer, which contains the user's data, to be - // cleared, and causes mFeedbackLog, which is ready to collect information in case the user - // wants to upload, to be closed. This is good because we don't need to log information about - // what the user is typing in the feedback dialog, but bad because this data must be uploaded. - // Here we save the LogBuffer and Log so the feedback dialog can later access their data. - private LogBuffer mSavedFeedbackLogBuffer; - private ResearchLog mSavedFeedbackLog; - public void presentFeedbackDialog(LatinIME latinIME) { + if (isMakingUserRecording()) { + saveRecording(); + } mInFeedbackDialog = true; mSavedFeedbackLogBuffer = mFeedbackLogBuffer; mSavedFeedbackLog = mFeedbackLog; @@ -569,7 +590,90 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang // Feedback dialog will not close them. mFeedbackLogBuffer = null; mFeedbackLog = null; - latinIME.launchKeyboardedDialogActivity(FeedbackActivity.class); + + Intent intent = new Intent(); + intent.setClass(mLatinIME, FeedbackActivity.class); + if (mFeedbackDialogBundle != null) { + Log.d(TAG, "putting extra in feedbackdialogbundle"); + intent.putExtras(mFeedbackDialogBundle); + } + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + latinIME.startActivity(intent); + } + + public void setFeedbackDialogBundle(final Bundle bundle) { + mFeedbackDialogBundle = bundle; + } + + public void startRecording() { + final Resources res = mLatinIME.getResources(); + Toast.makeText(mLatinIME, + res.getString(R.string.research_feedback_demonstration_instructions), + Toast.LENGTH_LONG).show(); + startRecordingInternal(); + } + + private void startRecordingInternal() { + commitCurrentLogUnit(); + if (mUserRecordingLog != null) { + mUserRecordingLog.abort(); + } + mUserRecordingFile = createUserRecordingFile(mFilesDir); + mUserRecordingLog = new ResearchLog(mUserRecordingFile, mLatinIME); + mUserRecordingLogBuffer = new LogBuffer(); + resetRecordingTimer(); + } + + private boolean isMakingUserRecording() { + return mUserRecordingLog != null; + } + + private void resetRecordingTimer() { + if (mUserRecordingTimeoutHandler == null) { + mUserRecordingTimeoutHandler = new Handler(); + } + clearRecordingTimer(); + mUserRecordingTimeoutHandler.postDelayed(mRecordingHandlerTimeoutRunnable, + USER_RECORDING_TIMEOUT_MS); + } + + private void clearRecordingTimer() { + mUserRecordingTimeoutHandler.removeCallbacks(mRecordingHandlerTimeoutRunnable); + } + + private Runnable mRecordingHandlerTimeoutRunnable = new Runnable() { + @Override + public void run() { + cancelRecording(); + requestIndicatorRedraw(); + final Resources res = mLatinIME.getResources(); + Toast.makeText(mLatinIME, res.getString(R.string.research_feedback_recording_failure), + Toast.LENGTH_LONG).show(); + } + }; + + private void cancelRecording() { + if (mUserRecordingLog != null) { + mUserRecordingLog.abort(); + } + mUserRecordingLog = null; + mUserRecordingLogBuffer = null; + if (mFeedbackDialogBundle != null) { + mFeedbackDialogBundle.putBoolean("HasRecording", false); + } + } + + private void saveRecording() { + commitCurrentLogUnit(); + publishLogBuffer(mUserRecordingLogBuffer, mUserRecordingLog, true); + mUserRecordingLog.close(null); + mUserRecordingLog = null; + mUserRecordingLogBuffer = null; + + if (mFeedbackDialogBundle != null) { + mFeedbackDialogBundle.putBoolean(FeedbackFragment.KEY_HAS_USER_RECORDING, true); + } + clearRecordingTimer(); } // TODO: currently unreachable. Remove after being sure enable/disable is @@ -631,52 +735,39 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang return null; } - static class LogStatement { - final String mName; - - // mIsPotentiallyPrivate indicates that event contains potentially private information. If - // the word that this event is a part of is determined to be privacy-sensitive, then this - // event should not be included in the output log. The system waits to output until the - // containing word is known. - final boolean mIsPotentiallyPrivate; - - // mIsPotentiallyRevealing indicates that this statement may disclose details about other - // words typed in other LogUnits. This can happen if the user is not inserting spaces, and - // data from Suggestions and/or Composing text reveals the entire "megaword". For example, - // say the user is typing "for the win", and the system wants to record the bigram "the - // win". If the user types "forthe", omitting the space, the system will give "for the" as - // a suggestion. If the user accepts the autocorrection, the suggestion for "for the" is - // included in the log for the word "the", disclosing that the previous word had been "for". - // For now, we simply do not include this data when logging part of a "megaword". - final boolean mIsPotentiallyRevealing; - - // mKeys stores the names that are the attributes in the output json objects - final String[] mKeys; - private static final String[] NULL_KEYS = new String[0]; - - LogStatement(final String name, final boolean isPotentiallyPrivate, - final boolean isPotentiallyRevealing, final String... keys) { - mName = name; - mIsPotentiallyPrivate = isPotentiallyPrivate; - mIsPotentiallyRevealing = isPotentiallyRevealing; - mKeys = (keys == null) ? NULL_KEYS : keys; - } - } - private static final LogStatement LOGSTATEMENT_FEEDBACK = - new LogStatement("UserFeedback", false, false, "contents", "accountName"); + new LogStatement("UserFeedback", false, false, "contents", "accountName", "recording"); public void sendFeedback(final String feedbackContents, final boolean includeHistory, - final boolean isIncludingAccountName) { + final boolean isIncludingAccountName, final boolean isIncludingRecording) { if (mSavedFeedbackLogBuffer == null) { return; } if (!includeHistory) { mSavedFeedbackLogBuffer.clear(); } + String recording = ""; + if (isIncludingRecording) { + // Try to read recording from recently written json file + if (mUserRecordingFile != null) { + try { + final FileChannel channel = + new FileInputStream(mUserRecordingFile).getChannel(); + final MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, + channel.size()); + // Android's openFileOutput() creates the file, so we use Android's default + // Charset (UTF-8) here to read it. + recording = Charset.defaultCharset().decode(buffer).toString(); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } final LogUnit feedbackLogUnit = new LogUnit(); final String accountName = isIncludingAccountName ? getAccountName() : ""; feedbackLogUnit.addLogStatement(LOGSTATEMENT_FEEDBACK, SystemClock.uptimeMillis(), - feedbackContents, accountName); + feedbackContents, accountName, recording); mFeedbackLogBuffer.shiftIn(feedbackLogUnit); publishLogBuffer(mFeedbackLogBuffer, mSavedFeedbackLog, true /* isIncludingPrivateData */); mSavedFeedbackLog.close(new Runnable() { @@ -685,13 +776,25 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang uploadNow(); } }); + + if (isIncludingRecording && DEBUG_REPLAY_AFTER_FEEDBACK) { + final Handler handler = new Handler(); + handler.postDelayed(new Runnable() { + @Override + public void run() { + final ReplayData replayData = + mMotionEventReader.readMotionEventData(mUserRecordingFile); + mReplayer.replay(replayData); + } + }, 1000); + } } public void uploadNow() { if (DEBUG) { Log.d(TAG, "calling uploadNow()"); } - mLatinIME.startService(mUploadIntent); + mLatinIME.startService(mUploadNowIntent); } public void onLeavingSendFeedbackDialog() { @@ -734,11 +837,11 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang int height) { // TODO: Reimplement using a keyboard background image specific to the ResearchLogger // and remove this method. - // The check for MainKeyboardView ensures that a red border is only placed around - // the main keyboard, not every keyboard. + // The check for MainKeyboardView ensures that the indicator only decorates the main + // keyboard, not every keyboard. if (IS_SHOWING_INDICATOR && isAllowedToLog() && view instanceof MainKeyboardView) { final int savedColor = paint.getColor(); - paint.setColor(Color.RED); + paint.setColor(isMakingUserRecording() ? Color.YELLOW : Color.RED); final Style savedStyle = paint.getStyle(); paint.setStyle(Style.STROKE); final float savedStrokeWidth = paint.getStrokeWidth(); @@ -747,10 +850,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang canvas.drawLine(0, 0, 0, height, paint); canvas.drawLine(width, 0, width, height, paint); } else { - // Put a tiny red dot on the screen so a knowledgeable user can check whether - // it is enabled. The dot is actually a zero-width, zero-height rectangle, - // placed at the lower-right corner of the canvas, painted with a non-zero border - // width. + // Put a tiny dot on the screen so a knowledgeable user can check whether it is + // enabled. The dot is actually a zero-width, zero-height rectangle, placed at the + // lower-right corner of the canvas, painted with a non-zero border width. paint.setStrokeWidth(3); canvas.drawRect(width, height, width, height, paint); } @@ -770,7 +872,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang private synchronized void enqueueEvent(final LogUnit logUnit, final LogStatement logStatement, final Object... values) { - assert values.length == logStatement.mKeys.length; + assert values.length == logStatement.getKeys().length; if (isAllowedToLog() && logUnit != null) { final long time = SystemClock.uptimeMillis(); logUnit.addLogStatement(logStatement, time, values); @@ -801,6 +903,9 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang if (mFeedbackLogBuffer != null) { mFeedbackLogBuffer.shiftIn(mCurrentLogUnit); } + if (mUserRecordingLogBuffer != null) { + mUserRecordingLogBuffer.shiftIn(mCurrentLogUnit); + } mCurrentLogUnit = new LogUnit(); } else { if (DEBUG) { @@ -1058,7 +1163,7 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang * */ private static final LogStatement LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT = - new LogStatement("MotionEvent", true, false, "action", "MotionEvent"); + new LogStatement("MotionEvent", true, false, "action", "MotionEvent", "loggingRelated"); public static void mainKeyboardView_processMotionEvent(final MotionEvent me, final int action, final long eventTime, final int index, final int id, final int x, final int y) { if (me != null) { @@ -1075,12 +1180,16 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang } final ResearchLogger researchLogger = getInstance(); researchLogger.enqueueEvent(LOGSTATEMENT_MAIN_KEYBOARD_VIEW_PROCESS_MOTION_EVENT, - actionString, MotionEvent.obtain(me)); + actionString, MotionEvent.obtain(me), false); if (action == MotionEvent.ACTION_DOWN) { // Subtract 1 from eventTime so the down event is included in the later // LogUnit, not the earlier (the test is for inequality). researchLogger.setSavedDownEventTime(eventTime - 1); } + // Refresh the timer in case we are capturing user feedback. + if (researchLogger.isMakingUserRecording()) { + researchLogger.resetRecordingTimer(); + } } } @@ -1442,13 +1551,21 @@ public class ResearchLogger implements SharedPreferences.OnSharedPreferenceChang final int code) { if (key != null) { String outputText = key.getOutputText(); - getInstance().enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT, + final ResearchLogger researchLogger = getInstance(); + researchLogger.enqueueEvent(LOGSTATEMENT_POINTERTRACKER_CALLLISTENERONCODEINPUT, Constants.printableCode(scrubDigitFromCodePoint(code)), outputText == null ? null : scrubDigitsFromString(outputText.toString()), x, y, ignoreModifierKey, altersCode, key.isEnabled()); + if (code == Constants.CODE_RESEARCH) { + researchLogger.suppressResearchKeyMotionData(); + } } } + private void suppressResearchKeyMotionData() { + mCurrentLogUnit.removeResearchButtonInvocation(); + } + /** * Log a call to PointerTracker.callListenerCallListenerOnRelease(). * diff --git a/java/src/com/android/inputmethod/research/UploaderService.java b/java/src/com/android/inputmethod/research/UploaderService.java index 5e3cf55e4..89c67fbb2 100644 --- a/java/src/com/android/inputmethod/research/UploaderService.java +++ b/java/src/com/android/inputmethod/research/UploaderService.java @@ -51,7 +51,7 @@ public final class UploaderService extends IntentService { private static final boolean IS_INHIBITING_AUTO_UPLOAD = false && ProductionFlag.IS_EXPERIMENTAL_DEBUG; // Force false in production public static final long RUN_INTERVAL = AlarmManager.INTERVAL_HOUR; - private static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName() + public static final String EXTRA_UPLOAD_UNCONDITIONALLY = UploaderService.class.getName() + ".extra.UPLOAD_UNCONDITIONALLY"; private static final int BUF_SIZE = 1024 * 8; protected static final int TIMEOUT_IN_MS = 1000 * 4; @@ -131,7 +131,7 @@ public final class UploaderService extends IntentService { final File[] files = mFilesDir.listFiles(new FileFilter() { @Override public boolean accept(File pathname) { - return pathname.getName().startsWith(ResearchLogger.FILENAME_PREFIX) + return pathname.getName().startsWith(ResearchLogger.LOG_FILENAME_PREFIX) && !pathname.canWrite(); } }); |