diff options
Diffstat (limited to 'java/src/org/kelar')
307 files changed, 64110 insertions, 0 deletions
diff --git a/java/src/org/kelar/inputmethod/accessibility/AccessibilityLongPressTimer.java b/java/src/org/kelar/inputmethod/accessibility/AccessibilityLongPressTimer.java new file mode 100644 index 000000000..bafa94167 --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/AccessibilityLongPressTimer.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.accessibility; + +import android.content.Context; +import android.os.Handler; +import android.os.Message; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.latin.R; + +// Handling long press timer to show a more keys keyboard. +final class AccessibilityLongPressTimer extends Handler { + public interface LongPressTimerCallback { + public void performLongClickOn(Key key); + } + + private static final int MSG_LONG_PRESS = 1; + + private final LongPressTimerCallback mCallback; + private final long mConfigAccessibilityLongPressTimeout; + + public AccessibilityLongPressTimer(final LongPressTimerCallback callback, + final Context context) { + super(); + mCallback = callback; + mConfigAccessibilityLongPressTimeout = context.getResources().getInteger( + R.integer.config_accessibility_long_press_key_timeout); + } + + @Override + public void handleMessage(final Message msg) { + switch (msg.what) { + case MSG_LONG_PRESS: + cancelLongPress(); + mCallback.performLongClickOn((Key)msg.obj); + return; + default: + super.handleMessage(msg); + return; + } + } + + public void startLongPress(final Key key) { + cancelLongPress(); + final Message longPressMessage = obtainMessage(MSG_LONG_PRESS, key); + sendMessageDelayed(longPressMessage, mConfigAccessibilityLongPressTimeout); + } + + public void cancelLongPress() { + removeMessages(MSG_LONG_PRESS); + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/AccessibilityUtils.java b/java/src/org/kelar/inputmethod/accessibility/AccessibilityUtils.java new file mode 100644 index 000000000..03daeb260 --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/AccessibilityUtils.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.accessibility; + +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; +import android.os.SystemClock; +import android.provider.Settings; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import android.text.TextUtils; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.compat.SettingsSecureCompatUtils; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.utils.InputTypeUtils; + +public final class AccessibilityUtils { + private static final String TAG = AccessibilityUtils.class.getSimpleName(); + private static final String CLASS = AccessibilityUtils.class.getName(); + private static final String PACKAGE = + AccessibilityUtils.class.getPackage().getName(); + + private static final AccessibilityUtils sInstance = new AccessibilityUtils(); + + private Context mContext; + private AccessibilityManager mAccessibilityManager; + private AudioManager mAudioManager; + + /** The most recent auto-correction. */ + private String mAutoCorrectionWord; + + /** The most recent typed word for auto-correction. */ + private String mTypedWord; + + /* + * Setting this constant to {@code false} will disable all keyboard + * accessibility code, regardless of whether Accessibility is turned on in + * the system settings. It should ONLY be used in the event of an emergency. + */ + private static final boolean ENABLE_ACCESSIBILITY = true; + + public static void init(final Context context) { + if (!ENABLE_ACCESSIBILITY) return; + + // These only need to be initialized if the kill switch is off. + sInstance.initInternal(context); + } + + public static AccessibilityUtils getInstance() { + return sInstance; + } + + private AccessibilityUtils() { + // This class is not publicly instantiable. + } + + private void initInternal(final Context context) { + mContext = context; + mAccessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + /** + * Returns {@code true} if accessibility is enabled. Currently, this means + * that the kill switch is off and system accessibility is turned on. + * + * @return {@code true} if accessibility is enabled. + */ + public boolean isAccessibilityEnabled() { + return ENABLE_ACCESSIBILITY && mAccessibilityManager.isEnabled(); + } + + /** + * Returns {@code true} if touch exploration is enabled. Currently, this + * means that the kill switch is off, the device supports touch exploration, + * and system accessibility is turned on. + * + * @return {@code true} if touch exploration is enabled. + */ + public boolean isTouchExplorationEnabled() { + return isAccessibilityEnabled() && mAccessibilityManager.isTouchExplorationEnabled(); + } + + /** + * Returns {@true} if the provided event is a touch exploration (e.g. hover) + * event. This is used to determine whether the event should be processed by + * the touch exploration code within the keyboard. + * + * @param event The event to check. + * @return {@true} is the event is a touch exploration event + */ + public static boolean isTouchExplorationEvent(final MotionEvent event) { + final int action = event.getAction(); + return action == MotionEvent.ACTION_HOVER_ENTER + || action == MotionEvent.ACTION_HOVER_EXIT + || action == MotionEvent.ACTION_HOVER_MOVE; + } + + /** + * Returns whether the device should obscure typed password characters. + * Typically this means speaking "dot" in place of non-control characters. + * + * @return {@code true} if the device should obscure password characters. + */ + @SuppressWarnings("deprecation") + public boolean shouldObscureInput(final EditorInfo editorInfo) { + if (editorInfo == null) return false; + + // The user can optionally force speaking passwords. + if (SettingsSecureCompatUtils.ACCESSIBILITY_SPEAK_PASSWORD != null) { + final boolean speakPassword = Settings.Secure.getInt(mContext.getContentResolver(), + SettingsSecureCompatUtils.ACCESSIBILITY_SPEAK_PASSWORD, 0) != 0; + if (speakPassword) return false; + } + + // Always speak if the user is listening through headphones. + if (mAudioManager.isWiredHeadsetOn() || mAudioManager.isBluetoothA2dpOn()) { + return false; + } + + // Don't speak if the IME is connected to a password field. + return InputTypeUtils.isPasswordInputType(editorInfo.inputType); + } + + /** + * Sets the current auto-correction word and typed word. These may be used + * to provide the user with a spoken description of what auto-correction + * will occur when a key is typed. + * + * @param suggestedWords the list of suggested auto-correction words + */ + public void setAutoCorrection(final SuggestedWords suggestedWords) { + if (suggestedWords.mWillAutoCorrect) { + mAutoCorrectionWord = suggestedWords.getWord(SuggestedWords.INDEX_OF_AUTO_CORRECTION); + final SuggestedWords.SuggestedWordInfo typedWordInfo = suggestedWords.mTypedWordInfo; + if (null == typedWordInfo) { + mTypedWord = null; + } else { + mTypedWord = typedWordInfo.mWord; + } + } else { + mAutoCorrectionWord = null; + mTypedWord = null; + } + } + + /** + * Obtains a description for an auto-correction key, taking into account the + * currently typed word and auto-correction. + * + * @param keyCodeDescription spoken description of the key that will insert + * an auto-correction + * @param shouldObscure whether the key should be obscured + * @return a description including a description of the auto-correction, if + * needed + */ + public String getAutoCorrectionDescription( + final String keyCodeDescription, final boolean shouldObscure) { + if (!TextUtils.isEmpty(mAutoCorrectionWord)) { + if (!TextUtils.equals(mAutoCorrectionWord, mTypedWord)) { + if (shouldObscure) { + // This should never happen, but just in case... + return mContext.getString(R.string.spoken_auto_correct_obscured, + keyCodeDescription); + } + return mContext.getString(R.string.spoken_auto_correct, keyCodeDescription, + mTypedWord, mAutoCorrectionWord); + } + } + + return keyCodeDescription; + } + + /** + * Sends the specified text to the {@link AccessibilityManager} to be + * spoken. + * + * @param view The source view. + * @param text The text to speak. + */ + public void announceForAccessibility(final View view, final CharSequence text) { + if (!mAccessibilityManager.isEnabled()) { + Log.e(TAG, "Attempted to speak when accessibility was disabled!"); + return; + } + + // The following is a hack to avoid using the heavy-weight TextToSpeech + // class. Instead, we're just forcing a fake AccessibilityEvent into + // the screen reader to make it speak. + final AccessibilityEvent event = AccessibilityEvent.obtain(); + + event.setPackageName(PACKAGE); + event.setClassName(CLASS); + event.setEventTime(SystemClock.uptimeMillis()); + event.setEnabled(true); + event.getText().add(text); + + // Platforms starting at SDK version 16 (Build.VERSION_CODES.JELLY_BEAN) should use + // announce events. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + event.setEventType(AccessibilityEventCompat.TYPE_ANNOUNCEMENT); + } else { + event.setEventType(AccessibilityEvent.TYPE_VIEW_FOCUSED); + } + + final ViewParent viewParent = view.getParent(); + if ((viewParent == null) || !(viewParent instanceof ViewGroup)) { + Log.e(TAG, "Failed to obtain ViewParent in announceForAccessibility"); + return; + } + + viewParent.requestSendAccessibilityEvent(view, event); + } + + /** + * Handles speaking the "connect a headset to hear passwords" notification + * when connecting to a password field. + * + * @param view The source view. + * @param editorInfo The input connection's editor info attribute. + * @param restarting Whether the connection is being restarted. + */ + public void onStartInputViewInternal(final View view, final EditorInfo editorInfo, + final boolean restarting) { + if (shouldObscureInput(editorInfo)) { + final CharSequence text = mContext.getText(R.string.spoken_use_headphones); + announceForAccessibility(view, text); + } + } + + /** + * Sends the specified {@link AccessibilityEvent} if accessibility is + * enabled. No operation if accessibility is disabled. + * + * @param event The event to send. + */ + public void requestSendAccessibilityEvent(final AccessibilityEvent event) { + if (mAccessibilityManager.isEnabled()) { + mAccessibilityManager.sendAccessibilityEvent(event); + } + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/KeyCodeDescriptionMapper.java b/java/src/org/kelar/inputmethod/accessibility/KeyCodeDescriptionMapper.java new file mode 100644 index 000000000..972324092 --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/KeyCodeDescriptionMapper.java @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.accessibility; + +import android.content.Context; +import android.content.res.Resources; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseIntArray; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.Locale; + +final class KeyCodeDescriptionMapper { + private static final String TAG = KeyCodeDescriptionMapper.class.getSimpleName(); + private static final String SPOKEN_LETTER_RESOURCE_NAME_FORMAT = "spoken_accented_letter_%04X"; + private static final String SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT = "spoken_symbol_%04X"; + private static final String SPOKEN_EMOJI_RESOURCE_NAME_FORMAT = "spoken_emoji_%04X"; + private static final String SPOKEN_EMOTICON_RESOURCE_NAME_PREFIX = "spoken_emoticon"; + private static final String SPOKEN_EMOTICON_CODE_POINT_FORMAT = "_%02X"; + + // The resource ID of the string spoken for obscured keys + private static final int OBSCURED_KEY_RES_ID = R.string.spoken_description_dot; + + private static final KeyCodeDescriptionMapper sInstance = new KeyCodeDescriptionMapper(); + + public static KeyCodeDescriptionMapper getInstance() { + return sInstance; + } + + // Sparse array of spoken description resource IDs indexed by key codes + private final SparseIntArray mKeyCodeMap = new SparseIntArray(); + + private KeyCodeDescriptionMapper() { + // Special non-character codes defined in Keyboard + mKeyCodeMap.put(Constants.CODE_SPACE, R.string.spoken_description_space); + mKeyCodeMap.put(Constants.CODE_DELETE, R.string.spoken_description_delete); + mKeyCodeMap.put(Constants.CODE_ENTER, R.string.spoken_description_return); + mKeyCodeMap.put(Constants.CODE_SETTINGS, R.string.spoken_description_settings); + mKeyCodeMap.put(Constants.CODE_SHIFT, R.string.spoken_description_shift); + mKeyCodeMap.put(Constants.CODE_SHORTCUT, R.string.spoken_description_mic); + mKeyCodeMap.put(Constants.CODE_SWITCH_ALPHA_SYMBOL, R.string.spoken_description_to_symbol); + mKeyCodeMap.put(Constants.CODE_TAB, R.string.spoken_description_tab); + mKeyCodeMap.put(Constants.CODE_LANGUAGE_SWITCH, + R.string.spoken_description_language_switch); + mKeyCodeMap.put(Constants.CODE_ACTION_NEXT, R.string.spoken_description_action_next); + mKeyCodeMap.put(Constants.CODE_ACTION_PREVIOUS, + R.string.spoken_description_action_previous); + mKeyCodeMap.put(Constants.CODE_EMOJI, R.string.spoken_description_emoji); + // Because the upper-case and lower-case mappings of the following letters is depending on + // the locale, the upper case descriptions should be defined here. The lower case + // descriptions are handled in {@link #getSpokenLetterDescriptionId(Context,int)}. + // U+0049: "I" LATIN CAPITAL LETTER I + // U+0069: "i" LATIN SMALL LETTER I + // U+0130: "İ" LATIN CAPITAL LETTER I WITH DOT ABOVE + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + mKeyCodeMap.put(0x0049, R.string.spoken_letter_0049); + mKeyCodeMap.put(0x0130, R.string.spoken_letter_0130); + } + + /** + * Returns the localized description of the action performed by a specified + * key based on the current keyboard state. + * + * @param context The package's context. + * @param keyboard The keyboard on which the key resides. + * @param key The key from which to obtain a description. + * @param shouldObscure {@true} if text (e.g. non-control) characters should be obscured. + * @return a character sequence describing the action performed by pressing the key + */ + public String getDescriptionForKey(final Context context, final Keyboard keyboard, + final Key key, final boolean shouldObscure) { + final int code = key.getCode(); + + if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) { + final String description = getDescriptionForSwitchAlphaSymbol(context, keyboard); + if (description != null) { + return description; + } + } + + if (code == Constants.CODE_SHIFT) { + return getDescriptionForShiftKey(context, keyboard); + } + + if (code == Constants.CODE_ENTER) { + // The following function returns the correct description in all action and + // regular enter cases, taking care of all modes. + return getDescriptionForActionKey(context, keyboard, key); + } + + if (code == Constants.CODE_OUTPUT_TEXT) { + final String outputText = key.getOutputText(); + final String description = getSpokenEmoticonDescription(context, outputText); + return TextUtils.isEmpty(description) ? outputText : description; + } + + // Just attempt to speak the description. + if (code != Constants.CODE_UNSPECIFIED) { + // If the key description should be obscured, now is the time to do it. + final boolean isDefinedNonCtrl = Character.isDefined(code) + && !Character.isISOControl(code); + if (shouldObscure && isDefinedNonCtrl) { + return context.getString(OBSCURED_KEY_RES_ID); + } + final String description = getDescriptionForCodePoint(context, code); + if (description != null) { + return description; + } + if (!TextUtils.isEmpty(key.getLabel())) { + return key.getLabel(); + } + return context.getString(R.string.spoken_description_unknown); + } + return null; + } + + /** + * Returns a context-specific description for the CODE_SWITCH_ALPHA_SYMBOL + * key or {@code null} if there is not a description provided for the + * current keyboard context. + * + * @param context The package's context. + * @param keyboard The keyboard on which the key resides. + * @return a character sequence describing the action performed by pressing the key + */ + private static String getDescriptionForSwitchAlphaSymbol(final Context context, + final Keyboard keyboard) { + final KeyboardId keyboardId = keyboard.mId; + final int elementId = keyboardId.mElementId; + final int resId; + + switch (elementId) { + case KeyboardId.ELEMENT_ALPHABET: + case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED: + resId = R.string.spoken_description_to_symbol; + break; + case KeyboardId.ELEMENT_SYMBOLS: + case KeyboardId.ELEMENT_SYMBOLS_SHIFTED: + resId = R.string.spoken_description_to_alpha; + break; + case KeyboardId.ELEMENT_PHONE: + resId = R.string.spoken_description_to_symbol; + break; + case KeyboardId.ELEMENT_PHONE_SYMBOLS: + resId = R.string.spoken_description_to_numeric; + break; + default: + Log.e(TAG, "Missing description for keyboard element ID:" + elementId); + return null; + } + return context.getString(resId); + } + + /** + * Returns a context-sensitive description of the "Shift" key. + * + * @param context The package's context. + * @param keyboard The keyboard on which the key resides. + * @return A context-sensitive description of the "Shift" key. + */ + private static String getDescriptionForShiftKey(final Context context, + final Keyboard keyboard) { + final KeyboardId keyboardId = keyboard.mId; + final int elementId = keyboardId.mElementId; + final int resId; + + switch (elementId) { + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED: + resId = R.string.spoken_description_caps_lock; + break; + case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED: + resId = R.string.spoken_description_shift_shifted; + break; + case KeyboardId.ELEMENT_SYMBOLS: + resId = R.string.spoken_description_symbols_shift; + break; + case KeyboardId.ELEMENT_SYMBOLS_SHIFTED: + resId = R.string.spoken_description_symbols_shift_shifted; + break; + default: + resId = R.string.spoken_description_shift; + } + return context.getString(resId); + } + + /** + * Returns a context-sensitive description of the "Enter" action key. + * + * @param context The package's context. + * @param keyboard The keyboard on which the key resides. + * @param key The key to describe. + * @return Returns a context-sensitive description of the "Enter" action key. + */ + private static String getDescriptionForActionKey(final Context context, final Keyboard keyboard, + final Key key) { + final KeyboardId keyboardId = keyboard.mId; + final int actionId = keyboardId.imeAction(); + final int resId; + + // Always use the label, if available. + if (!TextUtils.isEmpty(key.getLabel())) { + return key.getLabel().trim(); + } + + // Otherwise, use the action ID. + switch (actionId) { + case EditorInfo.IME_ACTION_SEARCH: + resId = R.string.spoken_description_search; + break; + case EditorInfo.IME_ACTION_GO: + resId = R.string.label_go_key; + break; + case EditorInfo.IME_ACTION_SEND: + resId = R.string.label_send_key; + break; + case EditorInfo.IME_ACTION_NEXT: + resId = R.string.label_next_key; + break; + case EditorInfo.IME_ACTION_DONE: + resId = R.string.label_done_key; + break; + case EditorInfo.IME_ACTION_PREVIOUS: + resId = R.string.label_previous_key; + break; + default: + resId = R.string.spoken_description_return; + } + return context.getString(resId); + } + + /** + * Returns a localized character sequence describing what will happen when + * the specified key is pressed based on its key code point. + * + * @param context The package's context. + * @param codePoint The code point from which to obtain a description. + * @return a character sequence describing the code point. + */ + public String getDescriptionForCodePoint(final Context context, final int codePoint) { + // If the key description should be obscured, now is the time to do it. + final int index = mKeyCodeMap.indexOfKey(codePoint); + if (index >= 0) { + return context.getString(mKeyCodeMap.valueAt(index)); + } + final String accentedLetter = getSpokenAccentedLetterDescription(context, codePoint); + if (accentedLetter != null) { + return accentedLetter; + } + // Here, <code>code</code> may be a base (non-accented) letter. + final String unsupportedSymbol = getSpokenSymbolDescription(context, codePoint); + if (unsupportedSymbol != null) { + return unsupportedSymbol; + } + final String emojiDescription = getSpokenEmojiDescription(context, codePoint); + if (emojiDescription != null) { + return emojiDescription; + } + if (Character.isDefined(codePoint) && !Character.isISOControl(codePoint)) { + return StringUtils.newSingleCodePointString(codePoint); + } + return null; + } + + // TODO: Remove this method once TTS supports those accented letters' verbalization. + private String getSpokenAccentedLetterDescription(final Context context, final int code) { + final boolean isUpperCase = Character.isUpperCase(code); + final int baseCode = isUpperCase ? Character.toLowerCase(code) : code; + final int baseIndex = mKeyCodeMap.indexOfKey(baseCode); + final int resId = (baseIndex >= 0) ? mKeyCodeMap.valueAt(baseIndex) + : getSpokenDescriptionId(context, baseCode, SPOKEN_LETTER_RESOURCE_NAME_FORMAT); + if (resId == 0) { + return null; + } + final String spokenText = context.getString(resId); + return isUpperCase ? context.getString(R.string.spoken_description_upper_case, spokenText) + : spokenText; + } + + // TODO: Remove this method once TTS supports those symbols' verbalization. + private String getSpokenSymbolDescription(final Context context, final int code) { + final int resId = getSpokenDescriptionId(context, code, SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT); + if (resId == 0) { + return null; + } + final String spokenText = context.getString(resId); + if (!TextUtils.isEmpty(spokenText)) { + return spokenText; + } + // If a translated description is empty, fall back to unknown symbol description. + return context.getString(R.string.spoken_symbol_unknown); + } + + // TODO: Remove this method once TTS supports emoji verbalization. + private String getSpokenEmojiDescription(final Context context, final int code) { + final int resId = getSpokenDescriptionId(context, code, SPOKEN_EMOJI_RESOURCE_NAME_FORMAT); + if (resId == 0) { + return null; + } + final String spokenText = context.getString(resId); + if (!TextUtils.isEmpty(spokenText)) { + return spokenText; + } + // If a translated description is empty, fall back to unknown emoji description. + return context.getString(R.string.spoken_emoji_unknown); + } + + private int getSpokenDescriptionId(final Context context, final int code, + final String resourceNameFormat) { + final String resourceName = String.format(Locale.ROOT, resourceNameFormat, code); + final Resources resources = context.getResources(); + // Note that the resource package name may differ from the context package name. + final String resourcePackageName = resources.getResourcePackageName( + R.string.spoken_description_unknown); + final int resId = resources.getIdentifier(resourceName, "string", resourcePackageName); + if (resId != 0) { + mKeyCodeMap.append(code, resId); + } + return resId; + } + + // TODO: Remove this method once TTS supports emoticon verbalization. + private static String getSpokenEmoticonDescription(final Context context, + final String outputText) { + final StringBuilder sb = new StringBuilder(SPOKEN_EMOTICON_RESOURCE_NAME_PREFIX); + final int textLength = outputText.length(); + for (int index = 0; index < textLength; index = outputText.offsetByCodePoints(index, 1)) { + final int codePoint = outputText.codePointAt(index); + sb.append(String.format(Locale.ROOT, SPOKEN_EMOTICON_CODE_POINT_FORMAT, codePoint)); + } + final String resourceName = sb.toString(); + final Resources resources = context.getResources(); + // Note that the resource package name may differ from the context package name. + final String resourcePackageName = resources.getResourcePackageName( + R.string.spoken_description_unknown); + final int resId = resources.getIdentifier(resourceName, "string", resourcePackageName); + return (resId == 0) ? null : resources.getString(resId); + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityDelegate.java b/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityDelegate.java new file mode 100644 index 000000000..9e6275d56 --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityDelegate.java @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.accessibility; + +import android.content.Context; +import android.os.SystemClock; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyDetector; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardView; + +/** + * This class represents a delegate that can be registered in a class that extends + * {@link KeyboardView} to enhance accessibility support via composition rather via inheritance. + * + * To implement accessibility mode, the target keyboard view has to:<p> + * - Call {@link #setKeyboard(Keyboard)} when a new keyboard is set to the keyboard view. + * - Dispatch a hover event by calling {@link #onHoverEnter(MotionEvent)}. + * + * @param <KV> The keyboard view class type. + */ +public class KeyboardAccessibilityDelegate<KV extends KeyboardView> + extends AccessibilityDelegateCompat { + private static final String TAG = KeyboardAccessibilityDelegate.class.getSimpleName(); + protected static final boolean DEBUG_HOVER = false; + + protected final KV mKeyboardView; + protected final KeyDetector mKeyDetector; + private Keyboard mKeyboard; + private KeyboardAccessibilityNodeProvider<KV> mAccessibilityNodeProvider; + private Key mLastHoverKey; + + public static final int HOVER_EVENT_POINTER_ID = 0; + + public KeyboardAccessibilityDelegate(final KV keyboardView, final KeyDetector keyDetector) { + super(); + mKeyboardView = keyboardView; + mKeyDetector = keyDetector; + + // Ensure that the view has an accessibility delegate. + ViewCompat.setAccessibilityDelegate(keyboardView, this); + } + + /** + * Called when the keyboard layout changes. + * <p> + * <b>Note:</b> This method will be called even if accessibility is not + * enabled. + * @param keyboard The keyboard that is being set to the wrapping view. + */ + public void setKeyboard(final Keyboard keyboard) { + if (keyboard == null) { + return; + } + if (mAccessibilityNodeProvider != null) { + mAccessibilityNodeProvider.setKeyboard(keyboard); + } + mKeyboard = keyboard; + } + + protected final Keyboard getKeyboard() { + return mKeyboard; + } + + protected final void setLastHoverKey(final Key key) { + mLastHoverKey = key; + } + + protected final Key getLastHoverKey() { + return mLastHoverKey; + } + + /** + * Sends a window state change event with the specified string resource id. + * + * @param resId The string resource id of the text to send with the event. + */ + protected void sendWindowStateChanged(final int resId) { + if (resId == 0) { + return; + } + final Context context = mKeyboardView.getContext(); + sendWindowStateChanged(context.getString(resId)); + } + + /** + * Sends a window state change event with the specified text. + * + * @param text The text to send with the event. + */ + protected void sendWindowStateChanged(final String text) { + final AccessibilityEvent stateChange = AccessibilityEvent.obtain( + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + mKeyboardView.onInitializeAccessibilityEvent(stateChange); + stateChange.getText().add(text); + stateChange.setContentDescription(null); + + final ViewParent parent = mKeyboardView.getParent(); + if (parent != null) { + parent.requestSendAccessibilityEvent(mKeyboardView, stateChange); + } + } + + /** + * Delegate method for View.getAccessibilityNodeProvider(). This method is called in SDK + * version 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) and higher to obtain the virtual + * node hierarchy provider. + * + * @param host The host view for the provider. + * @return The accessibility node provider for the current keyboard. + */ + @Override + public KeyboardAccessibilityNodeProvider<KV> getAccessibilityNodeProvider(final View host) { + return getAccessibilityNodeProvider(); + } + + /** + * @return A lazily-instantiated node provider for this view delegate. + */ + protected KeyboardAccessibilityNodeProvider<KV> getAccessibilityNodeProvider() { + // Instantiate the provide only when requested. Since the system + // will call this method multiple times it is a good practice to + // cache the provider instance. + if (mAccessibilityNodeProvider == null) { + mAccessibilityNodeProvider = + new KeyboardAccessibilityNodeProvider<>(mKeyboardView, this); + } + return mAccessibilityNodeProvider; + } + + /** + * Get a key that a hover event is on. + * + * @param event The hover event. + * @return key The key that the <code>event</code> is on. + */ + protected final Key getHoverKeyOf(final MotionEvent event) { + final int actionIndex = event.getActionIndex(); + final int x = (int)event.getX(actionIndex); + final int y = (int)event.getY(actionIndex); + return mKeyDetector.detectHitKey(x, y); + } + + /** + * Receives hover events when touch exploration is turned on in SDK versions ICS and higher. + * + * @param event The hover event. + * @return {@code true} if the event is handled. + */ + public boolean onHoverEvent(final MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + onHoverEnter(event); + break; + case MotionEvent.ACTION_HOVER_MOVE: + onHoverMove(event); + break; + case MotionEvent.ACTION_HOVER_EXIT: + onHoverExit(event); + break; + default: + Log.w(getClass().getSimpleName(), "Unknown hover event: " + event); + break; + } + return true; + } + + /** + * Process {@link MotionEvent#ACTION_HOVER_ENTER} event. + * + * @param event A hover enter event. + */ + protected void onHoverEnter(final MotionEvent event) { + final Key key = getHoverKeyOf(event); + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverEnter: key=" + key); + } + if (key != null) { + onHoverEnterTo(key); + } + setLastHoverKey(key); + } + + /** + * Process {@link MotionEvent#ACTION_HOVER_MOVE} event. + * + * @param event A hover move event. + */ + protected void onHoverMove(final MotionEvent event) { + final Key lastKey = getLastHoverKey(); + final Key key = getHoverKeyOf(event); + if (key != lastKey) { + if (lastKey != null) { + onHoverExitFrom(lastKey); + } + if (key != null) { + onHoverEnterTo(key); + } + } + if (key != null) { + onHoverMoveWithin(key); + } + setLastHoverKey(key); + } + + /** + * Process {@link MotionEvent#ACTION_HOVER_EXIT} event. + * + * @param event A hover exit event. + */ + protected void onHoverExit(final MotionEvent event) { + final Key lastKey = getLastHoverKey(); + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverExit: key=" + getHoverKeyOf(event) + " last=" + lastKey); + } + if (lastKey != null) { + onHoverExitFrom(lastKey); + } + final Key key = getHoverKeyOf(event); + // Make sure we're not getting an EXIT event because the user slid + // off the keyboard area, then force a key press. + if (key != null) { + onHoverExitFrom(key); + } + setLastHoverKey(null); + } + + /** + * Perform click on a key. + * + * @param key A key to be registered. + */ + public void performClickOn(final Key key) { + if (DEBUG_HOVER) { + Log.d(TAG, "performClickOn: key=" + key); + } + simulateTouchEvent(MotionEvent.ACTION_DOWN, key); + simulateTouchEvent(MotionEvent.ACTION_UP, key); + } + + /** + * Simulating a touch event by injecting a synthesized touch event into {@link KeyboardView}. + * + * @param touchAction The action of the synthesizing touch event. + * @param key The key that a synthesized touch event is on. + */ + private void simulateTouchEvent(final int touchAction, final Key key) { + final int x = key.getHitBox().centerX(); + final int y = key.getHitBox().centerY(); + final long eventTime = SystemClock.uptimeMillis(); + final MotionEvent touchEvent = MotionEvent.obtain( + eventTime, eventTime, touchAction, x, y, 0 /* metaState */); + mKeyboardView.onTouchEvent(touchEvent); + touchEvent.recycle(); + } + + /** + * Handles a hover enter event on a key. + * + * @param key The currently hovered key. + */ + protected void onHoverEnterTo(final Key key) { + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverEnterTo: key=" + key); + } + key.onPressed(); + mKeyboardView.invalidateKey(key); + final KeyboardAccessibilityNodeProvider<KV> provider = getAccessibilityNodeProvider(); + provider.onHoverEnterTo(key); + provider.performActionForKey(key, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS); + } + + /** + * Handles a hover move event on a key. + * + * @param key The currently hovered key. + */ + protected void onHoverMoveWithin(final Key key) { } + + /** + * Handles a hover exit event on a key. + * + * @param key The currently hovered key. + */ + protected void onHoverExitFrom(final Key key) { + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverExitFrom: key=" + key); + } + key.onReleased(); + mKeyboardView.invalidateKey(key); + final KeyboardAccessibilityNodeProvider<KV> provider = getAccessibilityNodeProvider(); + provider.onHoverExitFrom(key); + } + + /** + * Perform long click on a key. + * + * @param key A key to be long pressed on. + */ + public void performLongClickOn(final Key key) { + // A extended class should override this method to implement long press. + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityNodeProvider.java b/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityNodeProvider.java new file mode 100644 index 000000000..b50835eee --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityNodeProvider.java @@ -0,0 +1,339 @@ +/* + * 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 org.kelar.inputmethod.accessibility; + +import android.graphics.Rect; +import android.os.Bundle; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeProviderCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import android.util.Log; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardView; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +import java.util.List; + +/** + * Exposes a virtual view sub-tree for {@link KeyboardView} and generates + * {@link AccessibilityEvent}s for individual {@link Key}s. + * <p> + * A virtual sub-tree is composed of imaginary {@link View}s that are reported + * as a part of the view hierarchy for accessibility purposes. This enables + * custom views that draw complex content to report them selves as a tree of + * virtual views, thus conveying their logical structure. + * </p> + */ +final class KeyboardAccessibilityNodeProvider<KV extends KeyboardView> + extends AccessibilityNodeProviderCompat { + private static final String TAG = KeyboardAccessibilityNodeProvider.class.getSimpleName(); + + // From {@link android.view.accessibility.AccessibilityNodeInfo#UNDEFINED_ITEM_ID}. + private static final int UNDEFINED = Integer.MAX_VALUE; + + private final KeyCodeDescriptionMapper mKeyCodeDescriptionMapper; + private final AccessibilityUtils mAccessibilityUtils; + + /** Temporary rect used to calculate in-screen bounds. */ + private final Rect mTempBoundsInScreen = new Rect(); + + /** The parent view's cached on-screen location. */ + private final int[] mParentLocation = CoordinateUtils.newInstance(); + + /** The virtual view identifier for the focused node. */ + private int mAccessibilityFocusedView = UNDEFINED; + + /** The virtual view identifier for the hovering node. */ + private int mHoveringNodeId = UNDEFINED; + + /** The keyboard view to provide an accessibility node info. */ + private final KV mKeyboardView; + /** The accessibility delegate. */ + private final KeyboardAccessibilityDelegate<KV> mDelegate; + + /** The current keyboard. */ + private Keyboard mKeyboard; + + public KeyboardAccessibilityNodeProvider(final KV keyboardView, + final KeyboardAccessibilityDelegate<KV> delegate) { + super(); + mKeyCodeDescriptionMapper = KeyCodeDescriptionMapper.getInstance(); + mAccessibilityUtils = AccessibilityUtils.getInstance(); + mKeyboardView = keyboardView; + mDelegate = delegate; + + // Since this class is constructed lazily, we might not get a subsequent + // call to setKeyboard() and therefore need to call it now. + setKeyboard(keyboardView.getKeyboard()); + } + + /** + * Sets the keyboard represented by this node provider. + * + * @param keyboard The keyboard that is being set to the keyboard view. + */ + public void setKeyboard(final Keyboard keyboard) { + mKeyboard = keyboard; + } + + private Key getKeyOf(final int virtualViewId) { + if (mKeyboard == null) { + return null; + } + final List<Key> sortedKeys = mKeyboard.getSortedKeys(); + // Use a virtual view id as an index of the sorted keys list. + if (virtualViewId >= 0 && virtualViewId < sortedKeys.size()) { + return sortedKeys.get(virtualViewId); + } + return null; + } + + private int getVirtualViewIdOf(final Key key) { + if (mKeyboard == null) { + return View.NO_ID; + } + final List<Key> sortedKeys = mKeyboard.getSortedKeys(); + final int size = sortedKeys.size(); + for (int index = 0; index < size; index++) { + if (sortedKeys.get(index) == key) { + // Use an index of the sorted keys list as a virtual view id. + return index; + } + } + return View.NO_ID; + } + + /** + * Creates and populates an {@link AccessibilityEvent} for the specified key + * and event type. + * + * @param key A key on the host keyboard view. + * @param eventType The event type to create. + * @return A populated {@link AccessibilityEvent} for the key. + * @see AccessibilityEvent + */ + public AccessibilityEvent createAccessibilityEvent(final Key key, final int eventType) { + final int virtualViewId = getVirtualViewIdOf(key); + final String keyDescription = getKeyDescription(key); + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.setPackageName(mKeyboardView.getContext().getPackageName()); + event.setClassName(key.getClass().getName()); + event.setContentDescription(keyDescription); + event.setEnabled(true); + final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); + record.setSource(mKeyboardView, virtualViewId); + return event; + } + + public void onHoverEnterTo(final Key key) { + final int id = getVirtualViewIdOf(key); + if (id == View.NO_ID) { + return; + } + // Start hovering on the key. Because our accessibility model is lift-to-type, we should + // report the node info without click and long click actions to avoid unnecessary + // announcements. + mHoveringNodeId = id; + // Invalidate the node info of the key. + sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED); + sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER); + } + + public void onHoverExitFrom(final Key key) { + mHoveringNodeId = UNDEFINED; + // Invalidate the node info of the key to be able to revert the change we have done + // in {@link #onHoverEnterTo(Key)}. + sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED); + sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_EXIT); + } + + /** + * Returns an {@link AccessibilityNodeInfoCompat} representing a virtual + * view, i.e. a descendant of the host View, with the given <code>virtualViewId</code> or + * the host View itself if <code>virtualViewId</code> equals to {@link View#NO_ID}. + * <p> + * A virtual descendant is an imaginary View that is reported as a part of + * the view hierarchy for accessibility purposes. This enables custom views + * that draw complex content to report them selves as a tree of virtual + * views, thus conveying their logical structure. + * </p> + * <p> + * The implementer is responsible for obtaining an accessibility node info + * from the pool of reusable instances and setting the desired properties of + * the node info before returning it. + * </p> + * + * @param virtualViewId A client defined virtual view id. + * @return A populated {@link AccessibilityNodeInfoCompat} for a virtual descendant or the host + * View. + * @see AccessibilityNodeInfoCompat + */ + @Override + public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(final int virtualViewId) { + if (virtualViewId == UNDEFINED) { + return null; + } + if (virtualViewId == View.NO_ID) { + // We are requested to create an AccessibilityNodeInfo describing + // this View, i.e. the root of the virtual sub-tree. + final AccessibilityNodeInfoCompat rootInfo = + AccessibilityNodeInfoCompat.obtain(mKeyboardView); + ViewCompat.onInitializeAccessibilityNodeInfo(mKeyboardView, rootInfo); + updateParentLocation(); + + // Add the virtual children of the root View. + final List<Key> sortedKeys = mKeyboard.getSortedKeys(); + final int size = sortedKeys.size(); + for (int index = 0; index < size; index++) { + final Key key = sortedKeys.get(index); + if (key.isSpacer()) { + continue; + } + // Use an index of the sorted keys list as a virtual view id. + rootInfo.addChild(mKeyboardView, index); + } + return rootInfo; + } + + // Find the key that corresponds to the given virtual view id. + final Key key = getKeyOf(virtualViewId); + if (key == null) { + Log.e(TAG, "Invalid virtual view ID: " + virtualViewId); + return null; + } + final String keyDescription = getKeyDescription(key); + final Rect boundsInParent = key.getHitBox(); + + // Calculate the key's in-screen bounds. + mTempBoundsInScreen.set(boundsInParent); + mTempBoundsInScreen.offset( + CoordinateUtils.x(mParentLocation), CoordinateUtils.y(mParentLocation)); + final Rect boundsInScreen = mTempBoundsInScreen; + + // Obtain and initialize an AccessibilityNodeInfo with information about the virtual view. + final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(); + info.setPackageName(mKeyboardView.getContext().getPackageName()); + // info.setTextEntryKey(true); + info.setClassName(key.getClass().getName()); + info.setContentDescription(keyDescription); + info.setBoundsInParent(boundsInParent); + info.setBoundsInScreen(boundsInScreen); + info.setParent(mKeyboardView); + info.setSource(mKeyboardView, virtualViewId); + info.setEnabled(key.isEnabled()); + info.setVisibleToUser(true); + info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); + if (key.isLongPressEnabled()) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK); + } + + if (mAccessibilityFocusedView == virtualViewId) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS); + } else { + info.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS); + } + return info; + } + + @Override + public boolean performAction(final int virtualViewId, final int action, + final Bundle arguments) { + final Key key = getKeyOf(virtualViewId); + if (key == null) { + return false; + } + return performActionForKey(key, action); + } + + /** + * Performs the specified accessibility action for the given key. + * + * @param key The on which to perform the action. + * @param action The action to perform. + * @return The result of performing the action, or false if the action is not supported. + */ + boolean performActionForKey(final Key key, final int action) { + switch (action) { + case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS: + mAccessibilityFocusedView = getVirtualViewIdOf(key); + sendAccessibilityEventForKey( + key, AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED); + return true; + case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS: + mAccessibilityFocusedView = UNDEFINED; + sendAccessibilityEventForKey( + key, AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); + return true; + case AccessibilityNodeInfoCompat.ACTION_CLICK: + sendAccessibilityEventForKey(key, AccessibilityEvent.TYPE_VIEW_CLICKED); + mDelegate.performClickOn(key); + return true; + case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK: + sendAccessibilityEventForKey(key, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED); + mDelegate.performLongClickOn(key); + return true; + default: + return false; + } + } + + /** + * Sends an accessibility event for the given {@link Key}. + * + * @param key The key that's sending the event. + * @param eventType The type of event to send. + */ + void sendAccessibilityEventForKey(final Key key, final int eventType) { + final AccessibilityEvent event = createAccessibilityEvent(key, eventType); + mAccessibilityUtils.requestSendAccessibilityEvent(event); + } + + /** + * Returns the context-specific description for a {@link Key}. + * + * @param key The key to describe. + * @return The context-specific description of the key. + */ + private String getKeyDescription(final Key key) { + final EditorInfo editorInfo = mKeyboard.mId.mEditorInfo; + final boolean shouldObscure = mAccessibilityUtils.shouldObscureInput(editorInfo); + final SettingsValues currentSettings = Settings.getInstance().getCurrent(); + final String keyCodeDescription = mKeyCodeDescriptionMapper.getDescriptionForKey( + mKeyboardView.getContext(), mKeyboard, key, shouldObscure); + if (currentSettings.isWordSeparator(key.getCode())) { + return mAccessibilityUtils.getAutoCorrectionDescription( + keyCodeDescription, shouldObscure); + } + return keyCodeDescription; + } + + /** + * Updates the parent's on-screen location. + */ + private void updateParentLocation() { + mKeyboardView.getLocationOnScreen(mParentLocation); + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/MainKeyboardAccessibilityDelegate.java b/java/src/org/kelar/inputmethod/accessibility/MainKeyboardAccessibilityDelegate.java new file mode 100644 index 000000000..0275162b6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/MainKeyboardAccessibilityDelegate.java @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.accessibility; + +import android.content.Context; +import android.graphics.Rect; +import android.os.SystemClock; +import android.util.Log; +import android.util.SparseIntArray; +import android.view.MotionEvent; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyDetector; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.keyboard.MainKeyboardView; +import org.kelar.inputmethod.keyboard.PointerTracker; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +/** + * This class represents a delegate that can be registered in {@link MainKeyboardView} to enhance + * accessibility support via composition rather via inheritance. + */ +public final class MainKeyboardAccessibilityDelegate + extends KeyboardAccessibilityDelegate<MainKeyboardView> + implements AccessibilityLongPressTimer.LongPressTimerCallback { + private static final String TAG = MainKeyboardAccessibilityDelegate.class.getSimpleName(); + + /** Map of keyboard modes to resource IDs. */ + private static final SparseIntArray KEYBOARD_MODE_RES_IDS = new SparseIntArray(); + + static { + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_DATE, R.string.keyboard_mode_date); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_DATETIME, R.string.keyboard_mode_date_time); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_EMAIL, R.string.keyboard_mode_email); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_IM, R.string.keyboard_mode_im); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_NUMBER, R.string.keyboard_mode_number); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_PHONE, R.string.keyboard_mode_phone); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_TEXT, R.string.keyboard_mode_text); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_TIME, R.string.keyboard_mode_time); + KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_URL, R.string.keyboard_mode_url); + } + + /** The most recently set keyboard mode. */ + private int mLastKeyboardMode = KEYBOARD_IS_HIDDEN; + private static final int KEYBOARD_IS_HIDDEN = -1; + // The rectangle region to ignore hover events. + private final Rect mBoundsToIgnoreHoverEvent = new Rect(); + + + public MainKeyboardAccessibilityDelegate(final MainKeyboardView mainKeyboardView, + final KeyDetector keyDetector) { + super(mainKeyboardView, keyDetector); + } + + /** + * {@inheritDoc} + */ + @Override + public void setKeyboard(final Keyboard keyboard) { + if (keyboard == null) { + return; + } + final Keyboard lastKeyboard = getKeyboard(); + super.setKeyboard(keyboard); + final int lastKeyboardMode = mLastKeyboardMode; + mLastKeyboardMode = keyboard.mId.mMode; + + // Since this method is called even when accessibility is off, make sure + // to check the state before announcing anything. + if (!AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + return; + } + // Announce the language name only when the language is changed. + if (lastKeyboard == null || !keyboard.mId.mSubtype.equals(lastKeyboard.mId.mSubtype)) { + announceKeyboardLanguage(keyboard); + return; + } + // Announce the mode only when the mode is changed. + if (keyboard.mId.mMode != lastKeyboardMode) { + announceKeyboardMode(keyboard); + return; + } + // Announce the keyboard type only when the type is changed. + if (keyboard.mId.mElementId != lastKeyboard.mId.mElementId) { + announceKeyboardType(keyboard, lastKeyboard); + return; + } + } + + /** + * Called when the keyboard is hidden and accessibility is enabled. + */ + public void onHideWindow() { + if (mLastKeyboardMode != KEYBOARD_IS_HIDDEN) { + announceKeyboardHidden(); + } + mLastKeyboardMode = KEYBOARD_IS_HIDDEN; + } + + /** + * Announces which language of keyboard is being displayed. + * + * @param keyboard The new keyboard. + */ + private void announceKeyboardLanguage(final Keyboard keyboard) { + final String languageText = SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale( + keyboard.mId.mSubtype.getRawSubtype()); + sendWindowStateChanged(languageText); + } + + /** + * Announces which type of keyboard is being displayed. + * If the keyboard type is unknown, no announcement is made. + * + * @param keyboard The new keyboard. + */ + private void announceKeyboardMode(final Keyboard keyboard) { + final Context context = mKeyboardView.getContext(); + final int modeTextResId = KEYBOARD_MODE_RES_IDS.get(keyboard.mId.mMode); + if (modeTextResId == 0) { + return; + } + final String modeText = context.getString(modeTextResId); + final String text = context.getString(R.string.announce_keyboard_mode, modeText); + sendWindowStateChanged(text); + } + + /** + * Announces which type of keyboard is being displayed. + * + * @param keyboard The new keyboard. + * @param lastKeyboard The last keyboard. + */ + private void announceKeyboardType(final Keyboard keyboard, final Keyboard lastKeyboard) { + final int lastElementId = lastKeyboard.mId.mElementId; + final int resId; + switch (keyboard.mId.mElementId) { + case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET: + if (lastElementId == KeyboardId.ELEMENT_ALPHABET + || lastElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED) { + // Transition between alphabet mode and automatic shifted mode should be silently + // ignored because it can be determined by each key's talk back announce. + return; + } + resId = R.string.spoken_description_mode_alpha; + break; + case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED: + if (lastElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED) { + // Resetting automatic shifted mode by pressing the shift key causes the transition + // from automatic shifted to manual shifted that should be silently ignored. + return; + } + resId = R.string.spoken_description_shiftmode_on; + break; + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: + if (lastElementId == KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED) { + // Resetting caps locked mode by pressing the shift key causes the transition + // from shift locked to shift lock shifted that should be silently ignored. + return; + } + resId = R.string.spoken_description_shiftmode_locked; + break; + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED: + resId = R.string.spoken_description_shiftmode_locked; + break; + case KeyboardId.ELEMENT_SYMBOLS: + resId = R.string.spoken_description_mode_symbol; + break; + case KeyboardId.ELEMENT_SYMBOLS_SHIFTED: + resId = R.string.spoken_description_mode_symbol_shift; + break; + case KeyboardId.ELEMENT_PHONE: + resId = R.string.spoken_description_mode_phone; + break; + case KeyboardId.ELEMENT_PHONE_SYMBOLS: + resId = R.string.spoken_description_mode_phone_shift; + break; + default: + return; + } + sendWindowStateChanged(resId); + } + + /** + * Announces that the keyboard has been hidden. + */ + private void announceKeyboardHidden() { + sendWindowStateChanged(R.string.announce_keyboard_hidden); + } + + @Override + public void performClickOn(final Key key) { + final int x = key.getHitBox().centerX(); + final int y = key.getHitBox().centerY(); + if (DEBUG_HOVER) { + Log.d(TAG, "performClickOn: key=" + key + + " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y)); + } + if (mBoundsToIgnoreHoverEvent.contains(x, y)) { + // This hover exit event points to the key that should be ignored. + // Clear the ignoring region to handle further hover events. + mBoundsToIgnoreHoverEvent.setEmpty(); + return; + } + super.performClickOn(key); + } + + @Override + protected void onHoverEnterTo(final Key key) { + final int x = key.getHitBox().centerX(); + final int y = key.getHitBox().centerY(); + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverEnterTo: key=" + key + + " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y)); + } + if (mBoundsToIgnoreHoverEvent.contains(x, y)) { + return; + } + // This hover enter event points to the key that isn't in the ignoring region. + // Further hover events should be handled. + mBoundsToIgnoreHoverEvent.setEmpty(); + super.onHoverEnterTo(key); + } + + @Override + protected void onHoverExitFrom(final Key key) { + final int x = key.getHitBox().centerX(); + final int y = key.getHitBox().centerY(); + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverExitFrom: key=" + key + + " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y)); + } + super.onHoverExitFrom(key); + } + + @Override + public void performLongClickOn(final Key key) { + if (DEBUG_HOVER) { + Log.d(TAG, "performLongClickOn: key=" + key); + } + final PointerTracker tracker = PointerTracker.getPointerTracker(HOVER_EVENT_POINTER_ID); + final long eventTime = SystemClock.uptimeMillis(); + final int x = key.getHitBox().centerX(); + final int y = key.getHitBox().centerY(); + final MotionEvent downEvent = MotionEvent.obtain( + eventTime, eventTime, MotionEvent.ACTION_DOWN, x, y, 0 /* metaState */); + // Inject a fake down event to {@link PointerTracker} to handle a long press correctly. + tracker.processMotionEvent(downEvent, mKeyDetector); + downEvent.recycle(); + // Invoke {@link PointerTracker#onLongPressed()} as if a long press timeout has passed. + tracker.onLongPressed(); + // If {@link Key#hasNoPanelAutoMoreKeys()} is true (such as "0 +" key on the phone layout) + // or a key invokes IME switcher dialog, we should just ignore the next + // {@link #onRegisterHoverKey(Key,MotionEvent)}. It can be determined by whether + // {@link PointerTracker} is in operation or not. + if (tracker.isInOperation()) { + // This long press shows a more keys keyboard and further hover events should be + // handled. + mBoundsToIgnoreHoverEvent.setEmpty(); + return; + } + // This long press has handled at {@link MainKeyboardView#onLongPress(PointerTracker)}. + // We should ignore further hover events on this key. + mBoundsToIgnoreHoverEvent.set(key.getHitBox()); + if (key.hasNoPanelAutoMoreKey()) { + // This long press has registered a code point without showing a more keys keyboard. + // We should talk back the code point if possible. + final int codePointOfNoPanelAutoMoreKey = key.getMoreKeys()[0].mCode; + final String text = KeyCodeDescriptionMapper.getInstance().getDescriptionForCodePoint( + mKeyboardView.getContext(), codePointOfNoPanelAutoMoreKey); + if (text != null) { + sendWindowStateChanged(text); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/accessibility/MoreKeysKeyboardAccessibilityDelegate.java b/java/src/org/kelar/inputmethod/accessibility/MoreKeysKeyboardAccessibilityDelegate.java new file mode 100644 index 000000000..b0a6f8bed --- /dev/null +++ b/java/src/org/kelar/inputmethod/accessibility/MoreKeysKeyboardAccessibilityDelegate.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.accessibility; + +import android.graphics.Rect; +import android.util.Log; +import android.view.MotionEvent; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyDetector; +import org.kelar.inputmethod.keyboard.MoreKeysKeyboardView; +import org.kelar.inputmethod.keyboard.PointerTracker; + +/** + * This class represents a delegate that can be registered in {@link MoreKeysKeyboardView} to + * enhance accessibility support via composition rather via inheritance. + */ +public class MoreKeysKeyboardAccessibilityDelegate + extends KeyboardAccessibilityDelegate<MoreKeysKeyboardView> { + private static final String TAG = MoreKeysKeyboardAccessibilityDelegate.class.getSimpleName(); + + private final Rect mMoreKeysKeyboardValidBounds = new Rect(); + private static final int CLOSING_INSET_IN_PIXEL = 1; + private int mOpenAnnounceResId; + private int mCloseAnnounceResId; + + public MoreKeysKeyboardAccessibilityDelegate(final MoreKeysKeyboardView moreKeysKeyboardView, + final KeyDetector keyDetector) { + super(moreKeysKeyboardView, keyDetector); + } + + public void setOpenAnnounce(final int resId) { + mOpenAnnounceResId = resId; + } + + public void setCloseAnnounce(final int resId) { + mCloseAnnounceResId = resId; + } + + public void onShowMoreKeysKeyboard() { + sendWindowStateChanged(mOpenAnnounceResId); + } + + public void onDismissMoreKeysKeyboard() { + sendWindowStateChanged(mCloseAnnounceResId); + } + + @Override + protected void onHoverEnter(final MotionEvent event) { + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverEnter: key=" + getHoverKeyOf(event)); + } + super.onHoverEnter(event); + final int actionIndex = event.getActionIndex(); + final int x = (int)event.getX(actionIndex); + final int y = (int)event.getY(actionIndex); + final int pointerId = event.getPointerId(actionIndex); + final long eventTime = event.getEventTime(); + mKeyboardView.onDownEvent(x, y, pointerId, eventTime); + } + + @Override + protected void onHoverMove(final MotionEvent event) { + super.onHoverMove(event); + final int actionIndex = event.getActionIndex(); + final int x = (int)event.getX(actionIndex); + final int y = (int)event.getY(actionIndex); + final int pointerId = event.getPointerId(actionIndex); + final long eventTime = event.getEventTime(); + mKeyboardView.onMoveEvent(x, y, pointerId, eventTime); + } + + @Override + protected void onHoverExit(final MotionEvent event) { + final Key lastKey = getLastHoverKey(); + if (DEBUG_HOVER) { + Log.d(TAG, "onHoverExit: key=" + getHoverKeyOf(event) + " last=" + lastKey); + } + if (lastKey != null) { + super.onHoverExitFrom(lastKey); + } + setLastHoverKey(null); + final int actionIndex = event.getActionIndex(); + final int x = (int)event.getX(actionIndex); + final int y = (int)event.getY(actionIndex); + final int pointerId = event.getPointerId(actionIndex); + final long eventTime = event.getEventTime(); + // A hover exit event at one pixel width or height area on the edges of more keys keyboard + // are treated as closing. + mMoreKeysKeyboardValidBounds.set(0, 0, mKeyboardView.getWidth(), mKeyboardView.getHeight()); + mMoreKeysKeyboardValidBounds.inset(CLOSING_INSET_IN_PIXEL, CLOSING_INSET_IN_PIXEL); + if (mMoreKeysKeyboardValidBounds.contains(x, y)) { + // Invoke {@link MoreKeysKeyboardView#onUpEvent(int,int,int,long)} as if this hover + // exit event selects a key. + mKeyboardView.onUpEvent(x, y, pointerId, eventTime); + // TODO: Should fix this reference. This is a hack to clear the state of + // {@link PointerTracker}. + PointerTracker.dismissAllMoreKeysPanels(); + return; + } + // Close the more keys keyboard. + // TODO: Should fix this reference. This is a hack to clear the state of + // {@link PointerTracker}. + PointerTracker.dismissAllMoreKeysPanels(); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/ActivityManagerCompatUtils.java b/java/src/org/kelar/inputmethod/compat/ActivityManagerCompatUtils.java new file mode 100644 index 000000000..b39d274d7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/ActivityManagerCompatUtils.java @@ -0,0 +1,46 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.app.ActivityManager; +import android.content.Context; + +import java.lang.reflect.Method; + +public class ActivityManagerCompatUtils { + private static final Object LOCK = new Object(); + private static volatile Boolean sBoolean = null; + private static final Method METHOD_isLowRamDevice = CompatUtils.getMethod( + ActivityManager.class, "isLowRamDevice"); + + private ActivityManagerCompatUtils() { + // Do not instantiate this class. + } + + public static boolean isLowRamDevice(Context context) { + if (sBoolean == null) { + synchronized(LOCK) { + if (sBoolean == null) { + final ActivityManager am = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + sBoolean = (Boolean)CompatUtils.invoke(am, false, METHOD_isLowRamDevice); + } + } + } + return sBoolean; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/AppWorkaroundsHelper.java b/java/src/org/kelar/inputmethod/compat/AppWorkaroundsHelper.java new file mode 100644 index 000000000..42fbd62c4 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/AppWorkaroundsHelper.java @@ -0,0 +1,30 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.content.pm.PackageInfo; + +@SuppressWarnings("unused") +public class AppWorkaroundsHelper { + private AppWorkaroundsHelper() { + // This helper class is not publicly instantiable. + } + + public static boolean evaluateIsBrokenByRecorrection(final PackageInfo info) { + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/AppWorkaroundsUtils.java b/java/src/org/kelar/inputmethod/compat/AppWorkaroundsUtils.java new file mode 100644 index 000000000..5e0187813 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/AppWorkaroundsUtils.java @@ -0,0 +1,60 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.content.pm.PackageInfo; +import android.os.Build.VERSION_CODES; + +/** + * A class to encapsulate work-arounds specific to particular apps. + */ +public class AppWorkaroundsUtils { + private final PackageInfo mPackageInfo; // May be null + private final boolean mIsBrokenByRecorrection; + + public AppWorkaroundsUtils(final PackageInfo packageInfo) { + mPackageInfo = packageInfo; + mIsBrokenByRecorrection = AppWorkaroundsHelper.evaluateIsBrokenByRecorrection( + packageInfo); + } + + public boolean isBrokenByRecorrection() { + return mIsBrokenByRecorrection; + } + + public boolean isBeforeJellyBean() { + if (null == mPackageInfo || null == mPackageInfo.applicationInfo) { + return false; + } + return mPackageInfo.applicationInfo.targetSdkVersion < VERSION_CODES.JELLY_BEAN; + } + + @Override + public String toString() { + if (null == mPackageInfo || null == mPackageInfo.applicationInfo) { + return ""; + } + final StringBuilder s = new StringBuilder(); + s.append("Target application : ") + .append(mPackageInfo.applicationInfo.name) + .append("\nPackage : ") + .append(mPackageInfo.applicationInfo.packageName) + .append("\nTarget app sdk version : ") + .append(mPackageInfo.applicationInfo.targetSdkVersion); + return s.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/BuildCompatUtils.java b/java/src/org/kelar/inputmethod/compat/BuildCompatUtils.java new file mode 100644 index 000000000..c1080eb4c --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/BuildCompatUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.os.Build; + +public final class BuildCompatUtils { + private BuildCompatUtils() { + // This utility class is not publicly instantiable. + } + + private static final boolean IS_RELEASE_BUILD = Build.VERSION.CODENAME.equals("REL"); + + /** + * The "effective" API version. + * {@link android.os.Build.VERSION#SDK_INT} if the platform is a release build. + * {@link android.os.Build.VERSION#SDK_INT} plus 1 if the platform is a development build. + */ + public static final int EFFECTIVE_SDK_INT = IS_RELEASE_BUILD + ? Build.VERSION.SDK_INT + : Build.VERSION.SDK_INT + 1; +} diff --git a/java/src/org/kelar/inputmethod/compat/CharacterCompat.java b/java/src/org/kelar/inputmethod/compat/CharacterCompat.java new file mode 100644 index 000000000..601a5dcf6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/CharacterCompat.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import java.lang.reflect.Method; + +public final class CharacterCompat { + // Note that Character.isAlphabetic(int), has been introduced in API level 19 + // (Build.VERSION_CODE.KITKAT). + private static final Method METHOD_isAlphabetic = CompatUtils.getMethod( + Character.class, "isAlphabetic", int.class); + + private CharacterCompat() { + // This utility class is not publicly instantiable. + } + + public static boolean isAlphabetic(final int code) { + if (METHOD_isAlphabetic != null) { + return (Boolean)CompatUtils.invoke(null, false, METHOD_isAlphabetic, code); + } + switch (Character.getType(code)) { + case Character.UPPERCASE_LETTER: + case Character.LOWERCASE_LETTER: + case Character.TITLECASE_LETTER: + case Character.MODIFIER_LETTER: + case Character.OTHER_LETTER: + case Character.LETTER_NUMBER: + return true; + default: + return false; + } + } +} diff --git a/java/src/org/kelar/inputmethod/compat/CompatUtils.java b/java/src/org/kelar/inputmethod/compat/CompatUtils.java new file mode 100644 index 000000000..475685927 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/CompatUtils.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import android.text.TextUtils; +import android.util.Log; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public final class CompatUtils { + private static final String TAG = CompatUtils.class.getSimpleName(); + + private CompatUtils() { + // This utility class is not publicly instantiable. + } + + public static Class<?> getClass(final String className) { + try { + return Class.forName(className); + } catch (final ClassNotFoundException e) { + return null; + } + } + + public static Method getMethod(final Class<?> targetClass, final String name, + final Class<?>... parameterTypes) { + if (targetClass == null || TextUtils.isEmpty(name)) { + return null; + } + try { + return targetClass.getMethod(name, parameterTypes); + } catch (final SecurityException | NoSuchMethodException e) { + // ignore + } + return null; + } + + public static Field getField(final Class<?> targetClass, final String name) { + if (targetClass == null || TextUtils.isEmpty(name)) { + return null; + } + try { + return targetClass.getField(name); + } catch (final SecurityException | NoSuchFieldException e) { + // ignore + } + return null; + } + + public static Constructor<?> getConstructor(final Class<?> targetClass, + final Class<?> ... types) { + if (targetClass == null || types == null) { + return null; + } + try { + return targetClass.getConstructor(types); + } catch (final SecurityException | NoSuchMethodException e) { + // ignore + } + return null; + } + + public static Object newInstance(final Constructor<?> constructor, final Object ... args) { + if (constructor == null) { + return null; + } + try { + return constructor.newInstance(args); + } catch (final InstantiationException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException e) { + Log.e(TAG, "Exception in newInstance", e); + } + return null; + } + + public static Object invoke(final Object receiver, final Object defaultValue, + final Method method, final Object... args) { + if (method == null) { + return defaultValue; + } + try { + return method.invoke(receiver, args); + } catch (final IllegalAccessException | IllegalArgumentException + | InvocationTargetException e) { + Log.e(TAG, "Exception in invoke", e); + } + return defaultValue; + } + + public static Object getFieldValue(final Object receiver, final Object defaultValue, + final Field field) { + if (field == null) { + return defaultValue; + } + try { + return field.get(receiver); + } catch (final IllegalAccessException | IllegalArgumentException e) { + Log.e(TAG, "Exception in getFieldValue", e); + } + return defaultValue; + } + + public static void setFieldValue(final Object receiver, final Field field, final Object value) { + if (field == null) { + return; + } + try { + field.set(receiver, value); + } catch (final IllegalAccessException | IllegalArgumentException e) { + Log.e(TAG, "Exception in setFieldValue", e); + } + } + + public static ClassWrapper getClassWrapper(final String className) { + return new ClassWrapper(getClass(className)); + } + + public static final class ClassWrapper { + private final Class<?> mClass; + public ClassWrapper(final Class<?> targetClass) { + mClass = targetClass; + } + + public boolean exists() { + return mClass != null; + } + + public <T> ToObjectMethodWrapper<T> getMethod(final String name, + final T defaultValue, final Class<?>... parameterTypes) { + return new ToObjectMethodWrapper<>(CompatUtils.getMethod(mClass, name, parameterTypes), + defaultValue); + } + + public ToIntMethodWrapper getPrimitiveMethod(final String name, final int defaultValue, + final Class<?>... parameterTypes) { + return new ToIntMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes), + defaultValue); + } + + public ToFloatMethodWrapper getPrimitiveMethod(final String name, final float defaultValue, + final Class<?>... parameterTypes) { + return new ToFloatMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes), + defaultValue); + } + + public ToBooleanMethodWrapper getPrimitiveMethod(final String name, + final boolean defaultValue, final Class<?>... parameterTypes) { + return new ToBooleanMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes), + defaultValue); + } + } + + public static final class ToObjectMethodWrapper<T> { + private final Method mMethod; + private final T mDefaultValue; + public ToObjectMethodWrapper(final Method method, final T defaultValue) { + mMethod = method; + mDefaultValue = defaultValue; + } + @SuppressWarnings("unchecked") + public T invoke(final Object receiver, final Object... args) { + return (T) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args); + } + } + + public static final class ToIntMethodWrapper { + private final Method mMethod; + private final int mDefaultValue; + public ToIntMethodWrapper(final Method method, final int defaultValue) { + mMethod = method; + mDefaultValue = defaultValue; + } + public int invoke(final Object receiver, final Object... args) { + return (int) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args); + } + } + + public static final class ToFloatMethodWrapper { + private final Method mMethod; + private final float mDefaultValue; + public ToFloatMethodWrapper(final Method method, final float defaultValue) { + mMethod = method; + mDefaultValue = defaultValue; + } + public float invoke(final Object receiver, final Object... args) { + return (float) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args); + } + } + + public static final class ToBooleanMethodWrapper { + private final Method mMethod; + private final boolean mDefaultValue; + public ToBooleanMethodWrapper(final Method method, final boolean defaultValue) { + mMethod = method; + mDefaultValue = defaultValue; + } + public boolean invoke(final Object receiver, final Object... args) { + return (boolean) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args); + } + } +} diff --git a/java/src/org/kelar/inputmethod/compat/ConnectivityManagerCompatUtils.java b/java/src/org/kelar/inputmethod/compat/ConnectivityManagerCompatUtils.java new file mode 100644 index 000000000..469c8d590 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/ConnectivityManagerCompatUtils.java @@ -0,0 +1,36 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.net.ConnectivityManager; + +import java.lang.reflect.Method; + +public final class ConnectivityManagerCompatUtils { + // ConnectivityManager#isActiveNetworkMetered() has been introduced + // in API level 16 (Build.VERSION_CODES.JELLY_BEAN). + private static final Method METHOD_isActiveNetworkMetered = CompatUtils.getMethod( + ConnectivityManager.class, "isActiveNetworkMetered"); + + public static boolean isActiveNetworkMetered(final ConnectivityManager manager) { + return (Boolean)CompatUtils.invoke(manager, + // If the API telling whether the network is metered or not is not available, + // then the closest thing is "if it's a mobile connection". + manager.getActiveNetworkInfo().getType() == ConnectivityManager.TYPE_MOBILE, + METHOD_isActiveNetworkMetered); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/CursorAnchorInfoCompatWrapper.java b/java/src/org/kelar/inputmethod/compat/CursorAnchorInfoCompatWrapper.java new file mode 100644 index 000000000..203f99109 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/CursorAnchorInfoCompatWrapper.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.annotation.TargetApi; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.os.Build; +import android.view.inputmethod.CursorAnchorInfo; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A wrapper for {@link CursorAnchorInfo}, which has been introduced in API Level 21. You can use + * this wrapper to avoid direct dependency on newly introduced types. + */ +public class CursorAnchorInfoCompatWrapper { + + /** + * The insertion marker or character bounds have at least one visible region. + */ + public static final int FLAG_HAS_VISIBLE_REGION = 0x01; + + /** + * The insertion marker or character bounds have at least one invisible (clipped) region. + */ + public static final int FLAG_HAS_INVISIBLE_REGION = 0x02; + + /** + * The insertion marker or character bounds is placed at right-to-left (RTL) character. + */ + public static final int FLAG_IS_RTL = 0x04; + + CursorAnchorInfoCompatWrapper() { + // This class is not publicly instantiable. + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Nullable + public static CursorAnchorInfoCompatWrapper wrap(@Nullable final CursorAnchorInfo instance) { + if (BuildCompatUtils.EFFECTIVE_SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return null; + } + if (instance == null) { + return null; + } + return new RealWrapper(instance); + } + + public int getSelectionStart() { + throw new UnsupportedOperationException("not supported."); + } + + public int getSelectionEnd() { + throw new UnsupportedOperationException("not supported."); + } + + public CharSequence getComposingText() { + throw new UnsupportedOperationException("not supported."); + } + + public int getComposingTextStart() { + throw new UnsupportedOperationException("not supported."); + } + + public Matrix getMatrix() { + throw new UnsupportedOperationException("not supported."); + } + + @SuppressWarnings("unused") + public RectF getCharacterBounds(final int index) { + throw new UnsupportedOperationException("not supported."); + } + + @SuppressWarnings("unused") + public int getCharacterBoundsFlags(final int index) { + throw new UnsupportedOperationException("not supported."); + } + + public float getInsertionMarkerBaseline() { + throw new UnsupportedOperationException("not supported."); + } + + public float getInsertionMarkerBottom() { + throw new UnsupportedOperationException("not supported."); + } + + public float getInsertionMarkerHorizontal() { + throw new UnsupportedOperationException("not supported."); + } + + public float getInsertionMarkerTop() { + throw new UnsupportedOperationException("not supported."); + } + + public int getInsertionMarkerFlags() { + throw new UnsupportedOperationException("not supported."); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static final class RealWrapper extends CursorAnchorInfoCompatWrapper { + + @Nonnull + private final CursorAnchorInfo mInstance; + + public RealWrapper(@Nonnull final CursorAnchorInfo info) { + mInstance = info; + } + + @Override + public int getSelectionStart() { + return mInstance.getSelectionStart(); + } + + @Override + public int getSelectionEnd() { + return mInstance.getSelectionEnd(); + } + + @Override + public CharSequence getComposingText() { + return mInstance.getComposingText(); + } + + @Override + public int getComposingTextStart() { + return mInstance.getComposingTextStart(); + } + + @Override + public Matrix getMatrix() { + return mInstance.getMatrix(); + } + + @Override + public RectF getCharacterBounds(final int index) { + return mInstance.getCharacterBounds(index); + } + + @Override + public int getCharacterBoundsFlags(final int index) { + return mInstance.getCharacterBoundsFlags(index); + } + + @Override + public float getInsertionMarkerBaseline() { + return mInstance.getInsertionMarkerBaseline(); + } + + @Override + public float getInsertionMarkerBottom() { + return mInstance.getInsertionMarkerBottom(); + } + + @Override + public float getInsertionMarkerHorizontal() { + return mInstance.getInsertionMarkerHorizontal(); + } + + @Override + public float getInsertionMarkerTop() { + return mInstance.getInsertionMarkerTop(); + } + + @Override + public int getInsertionMarkerFlags() { + return mInstance.getInsertionMarkerFlags(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/compat/EditorInfoCompatUtils.java b/java/src/org/kelar/inputmethod/compat/EditorInfoCompatUtils.java new file mode 100644 index 000000000..307f6891a --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/EditorInfoCompatUtils.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import android.view.inputmethod.EditorInfo; + +import java.lang.reflect.Field; +import java.util.Locale; + +public final class EditorInfoCompatUtils { + // Note that EditorInfo.IME_FLAG_FORCE_ASCII has been introduced + // in API level 16 (Build.VERSION_CODES.JELLY_BEAN). + private static final Field FIELD_IME_FLAG_FORCE_ASCII = CompatUtils.getField( + EditorInfo.class, "IME_FLAG_FORCE_ASCII"); + private static final Integer OBJ_IME_FLAG_FORCE_ASCII = (Integer) CompatUtils.getFieldValue( + null /* receiver */, null /* defaultValue */, FIELD_IME_FLAG_FORCE_ASCII); + private static final Field FIELD_HINT_LOCALES = CompatUtils.getField( + EditorInfo.class, "hintLocales"); + + private EditorInfoCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static boolean hasFlagForceAscii(final int imeOptions) { + if (OBJ_IME_FLAG_FORCE_ASCII == null) return false; + return (imeOptions & OBJ_IME_FLAG_FORCE_ASCII) != 0; + } + + public static String imeActionName(final int imeOptions) { + final int actionId = imeOptions & EditorInfo.IME_MASK_ACTION; + switch (actionId) { + case EditorInfo.IME_ACTION_UNSPECIFIED: + return "actionUnspecified"; + case EditorInfo.IME_ACTION_NONE: + return "actionNone"; + case EditorInfo.IME_ACTION_GO: + return "actionGo"; + case EditorInfo.IME_ACTION_SEARCH: + return "actionSearch"; + case EditorInfo.IME_ACTION_SEND: + return "actionSend"; + case EditorInfo.IME_ACTION_NEXT: + return "actionNext"; + case EditorInfo.IME_ACTION_DONE: + return "actionDone"; + case EditorInfo.IME_ACTION_PREVIOUS: + return "actionPrevious"; + default: + return "actionUnknown(" + actionId + ")"; + } + } + + public static String imeOptionsName(final int imeOptions) { + final String action = imeActionName(imeOptions); + final StringBuilder flags = new StringBuilder(); + if ((imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { + flags.append("flagNoEnterAction|"); + } + if ((imeOptions & EditorInfo.IME_FLAG_NAVIGATE_NEXT) != 0) { + flags.append("flagNavigateNext|"); + } + if ((imeOptions & EditorInfo.IME_FLAG_NAVIGATE_PREVIOUS) != 0) { + flags.append("flagNavigatePrevious|"); + } + if (hasFlagForceAscii(imeOptions)) { + flags.append("flagForceAscii|"); + } + return (action != null) ? flags + action : flags.toString(); + } + + public static Locale getPrimaryHintLocale(final EditorInfo editorInfo) { + if (editorInfo == null) { + return null; + } + final Object localeList = CompatUtils.getFieldValue(editorInfo, null, FIELD_HINT_LOCALES); + if (localeList == null) { + return null; + } + if (LocaleListCompatUtils.isEmpty(localeList)) { + return null; + } + return LocaleListCompatUtils.get(localeList, 0); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/InputConnectionCompatUtils.java b/java/src/org/kelar/inputmethod/compat/InputConnectionCompatUtils.java new file mode 100644 index 000000000..e9eecc511 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/InputConnectionCompatUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; + +public final class InputConnectionCompatUtils { + private static final CompatUtils.ClassWrapper sInputConnectionType; + private static final CompatUtils.ToBooleanMethodWrapper sRequestCursorUpdatesMethod; + static { + sInputConnectionType = new CompatUtils.ClassWrapper(InputConnection.class); + sRequestCursorUpdatesMethod = sInputConnectionType.getPrimitiveMethod( + "requestCursorUpdates", false, int.class); + } + + public static boolean isRequestCursorUpdatesAvailable() { + return sRequestCursorUpdatesMethod != null; + } + + /** + * Local copies of some constants in InputConnection until the SDK becomes publicly available. + */ + private static int CURSOR_UPDATE_IMMEDIATE = 1 << 0; + private static int CURSOR_UPDATE_MONITOR = 1 << 1; + + private static boolean requestCursorUpdatesImpl(final InputConnection inputConnection, + final int cursorUpdateMode) { + if (!isRequestCursorUpdatesAvailable()) { + return false; + } + return sRequestCursorUpdatesMethod.invoke(inputConnection, cursorUpdateMode); + } + + /** + * Requests the editor to call back {@link InputMethodManager#updateCursorAnchorInfo}. + * @param inputConnection the input connection to which the request is to be sent. + * @param enableMonitor {@code true} to request the editor to call back the method whenever the + * cursor/anchor position is changed. + * @param requestImmediateCallback {@code true} to request the editor to call back the method + * as soon as possible to notify the current cursor/anchor position to the input method. + * @return {@code false} if the request is not handled. Otherwise returns {@code true}. + */ + public static boolean requestCursorUpdates(final InputConnection inputConnection, + final boolean enableMonitor, final boolean requestImmediateCallback) { + final int cursorUpdateMode = (enableMonitor ? CURSOR_UPDATE_MONITOR : 0) + | (requestImmediateCallback ? CURSOR_UPDATE_IMMEDIATE : 0); + return requestCursorUpdatesImpl(inputConnection, cursorUpdateMode); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/InputMethodManagerCompatWrapper.java b/java/src/org/kelar/inputmethod/compat/InputMethodManagerCompatWrapper.java new file mode 100644 index 000000000..ce081a3f8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/InputMethodManagerCompatWrapper.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import android.content.Context; +import android.os.IBinder; +import android.view.inputmethod.InputMethodManager; + +import java.lang.reflect.Method; + +public final class InputMethodManagerCompatWrapper { + // Note that InputMethodManager.switchToNextInputMethod() has been introduced + // in API level 16 (Build.VERSION_CODES.JELLY_BEAN). + private static final Method METHOD_switchToNextInputMethod = CompatUtils.getMethod( + InputMethodManager.class, "switchToNextInputMethod", IBinder.class, boolean.class); + + // Note that InputMethodManager.shouldOfferSwitchingToNextInputMethod() has been introduced + // in API level 19 (Build.VERSION_CODES.KITKAT). + private static final Method METHOD_shouldOfferSwitchingToNextInputMethod = + CompatUtils.getMethod(InputMethodManager.class, + "shouldOfferSwitchingToNextInputMethod", IBinder.class); + + public final InputMethodManager mImm; + + public InputMethodManagerCompatWrapper(final Context context) { + mImm = (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public boolean switchToNextInputMethod(final IBinder token, final boolean onlyCurrentIme) { + return (Boolean)CompatUtils.invoke(mImm, false /* defaultValue */, + METHOD_switchToNextInputMethod, token, onlyCurrentIme); + } + + public boolean shouldOfferSwitchingToNextInputMethod(final IBinder token) { + return (Boolean)CompatUtils.invoke(mImm, false /* defaultValue */, + METHOD_shouldOfferSwitchingToNextInputMethod, token); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/InputMethodServiceCompatUtils.java b/java/src/org/kelar/inputmethod/compat/InputMethodServiceCompatUtils.java new file mode 100644 index 000000000..8549f9e1b --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/InputMethodServiceCompatUtils.java @@ -0,0 +1,37 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.inputmethodservice.InputMethodService; + +import java.lang.reflect.Method; + +public final class InputMethodServiceCompatUtils { + // Note that {@link InputMethodService#enableHardwareAcceleration} has been introduced + // in API level 17 (Build.VERSION_CODES.JELLY_BEAN_MR1). + private static final Method METHOD_enableHardwareAcceleration = + CompatUtils.getMethod(InputMethodService.class, "enableHardwareAcceleration"); + + private InputMethodServiceCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static boolean enableHardwareAcceleration(final InputMethodService ims) { + return (Boolean)CompatUtils.invoke(ims, false /* defaultValue */, + METHOD_enableHardwareAcceleration); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/InputMethodSubtypeCompatUtils.java b/java/src/org/kelar/inputmethod/compat/InputMethodSubtypeCompatUtils.java new file mode 100644 index 000000000..31d257ffe --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/InputMethodSubtypeCompatUtils.java @@ -0,0 +1,103 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.os.Build; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.Locale; + +import javax.annotation.Nonnull; + +public final class InputMethodSubtypeCompatUtils { + private static final String TAG = InputMethodSubtypeCompatUtils.class.getSimpleName(); + // Note that InputMethodSubtype(int nameId, int iconId, String locale, String mode, + // String extraValue, boolean isAuxiliary, boolean overridesImplicitlyEnabledSubtype, int id) + // has been introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1). + private static final Constructor<?> CONSTRUCTOR_INPUT_METHOD_SUBTYPE = + CompatUtils.getConstructor(InputMethodSubtype.class, + int.class, int.class, String.class, String.class, String.class, boolean.class, + boolean.class, int.class); + static { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + if (CONSTRUCTOR_INPUT_METHOD_SUBTYPE == null) { + android.util.Log.w(TAG, "Warning!!! Constructor is not defined."); + } + } + } + + // Note that {@link InputMethodSubtype#isAsciiCapable()} has been introduced in API level 19 + // (Build.VERSION_CODE.KITKAT). + private static final Method METHOD_isAsciiCapable = CompatUtils.getMethod( + InputMethodSubtype.class, "isAsciiCapable"); + + private InputMethodSubtypeCompatUtils() { + // This utility class is not publicly instantiable. + } + + @SuppressWarnings("deprecation") + @Nonnull + public static InputMethodSubtype newInputMethodSubtype(int nameId, int iconId, String locale, + String mode, String extraValue, boolean isAuxiliary, + boolean overridesImplicitlyEnabledSubtype, int id) { + if (CONSTRUCTOR_INPUT_METHOD_SUBTYPE == null + || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return new InputMethodSubtype(nameId, iconId, locale, mode, extraValue, isAuxiliary, + overridesImplicitlyEnabledSubtype); + } + return (InputMethodSubtype) CompatUtils.newInstance(CONSTRUCTOR_INPUT_METHOD_SUBTYPE, + nameId, iconId, locale, mode, extraValue, isAuxiliary, + overridesImplicitlyEnabledSubtype, id); + } + + public static boolean isAsciiCapable(final RichInputMethodSubtype subtype) { + return isAsciiCapable(subtype.getRawSubtype()); + } + + public static boolean isAsciiCapable(final InputMethodSubtype subtype) { + return isAsciiCapableWithAPI(subtype) + || subtype.containsExtraValueKey(Constants.Subtype.ExtraValue.ASCII_CAPABLE); + } + + // Note that InputMethodSubtype.getLanguageTag() is expected to be available in Android N+. + private static final Method GET_LANGUAGE_TAG = + CompatUtils.getMethod(InputMethodSubtype.class, "getLanguageTag"); + + public static Locale getLocaleObject(final InputMethodSubtype subtype) { + // Locale.forLanguageTag() is available only in Android L and later. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final String languageTag = (String) CompatUtils.invoke(subtype, null, GET_LANGUAGE_TAG); + if (!TextUtils.isEmpty(languageTag)) { + return Locale.forLanguageTag(languageTag); + } + } + return LocaleUtils.constructLocaleFromString(subtype.getLocale()); + } + + @UsedForTesting + public static boolean isAsciiCapableWithAPI(final InputMethodSubtype subtype) { + return (Boolean)CompatUtils.invoke(subtype, false, METHOD_isAsciiCapable); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/IntentCompatUtils.java b/java/src/org/kelar/inputmethod/compat/IntentCompatUtils.java new file mode 100644 index 000000000..efe3e9ccc --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/IntentCompatUtils.java @@ -0,0 +1,35 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.content.Intent; + +public final class IntentCompatUtils { + // Note that Intent.ACTION_USER_INITIALIZE have been introduced in API level 17 + // (Build.VERSION_CODE.JELLY_BEAN_MR1). + private static final String ACTION_USER_INITIALIZE = + (String)CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */, + CompatUtils.getField(Intent.class, "ACTION_USER_INITIALIZE")); + + private IntentCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static boolean is_ACTION_USER_INITIALIZE(final String action) { + return ACTION_USER_INITIALIZE != null && ACTION_USER_INITIALIZE.equals(action); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/LocaleListCompatUtils.java b/java/src/org/kelar/inputmethod/compat/LocaleListCompatUtils.java new file mode 100644 index 000000000..44d485f13 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/LocaleListCompatUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2016 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 org.kelar.inputmethod.compat; + +import java.lang.reflect.Method; +import java.util.Locale; + +public final class LocaleListCompatUtils { + private static final Class CLASS_LocaleList = CompatUtils.getClass("android.os.LocaleList"); + private static final Method METHOD_get = + CompatUtils.getMethod(CLASS_LocaleList, "get", int.class); + private static final Method METHOD_isEmpty = + CompatUtils.getMethod(CLASS_LocaleList, "isEmpty"); + + private LocaleListCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static boolean isEmpty(final Object localeList) { + return (Boolean) CompatUtils.invoke(localeList, Boolean.FALSE, METHOD_isEmpty); + } + + public static Locale get(final Object localeList, final int index) { + return (Locale) CompatUtils.invoke(localeList, null, METHOD_get, index); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/LocaleSpanCompatUtils.java b/java/src/org/kelar/inputmethod/compat/LocaleSpanCompatUtils.java new file mode 100644 index 000000000..431507f89 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/LocaleSpanCompatUtils.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.text.Spannable; +import android.text.Spanned; +import android.text.style.LocaleSpan; +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Locale; + +@UsedForTesting +public final class LocaleSpanCompatUtils { + private static final String TAG = LocaleSpanCompatUtils.class.getSimpleName(); + + // Note that LocaleSpan(Locale locale) has been introduced in API level 17 + // (Build.VERSION_CODE.JELLY_BEAN_MR1). + private static Class<?> getLocaleSpanClass() { + try { + return Class.forName("android.text.style.LocaleSpan"); + } catch (ClassNotFoundException e) { + return null; + } + } + private static final Class<?> LOCALE_SPAN_TYPE; + private static final Constructor<?> LOCALE_SPAN_CONSTRUCTOR; + private static final Method LOCALE_SPAN_GET_LOCALE; + static { + LOCALE_SPAN_TYPE = getLocaleSpanClass(); + LOCALE_SPAN_CONSTRUCTOR = CompatUtils.getConstructor(LOCALE_SPAN_TYPE, Locale.class); + LOCALE_SPAN_GET_LOCALE = CompatUtils.getMethod(LOCALE_SPAN_TYPE, "getLocale"); + } + + @UsedForTesting + public static boolean isLocaleSpanAvailable() { + return (LOCALE_SPAN_CONSTRUCTOR != null && LOCALE_SPAN_GET_LOCALE != null); + } + + @UsedForTesting + public static Object newLocaleSpan(final Locale locale) { + return CompatUtils.newInstance(LOCALE_SPAN_CONSTRUCTOR, locale); + } + + @UsedForTesting + public static Locale getLocaleFromLocaleSpan(final Object localeSpan) { + return (Locale) CompatUtils.invoke(localeSpan, null, LOCALE_SPAN_GET_LOCALE); + } + + /** + * Ensures that the specified range is covered with only one {@link LocaleSpan} with the given + * locale. If the region is already covered by one or more {@link LocaleSpan}, their ranges are + * updated so that each character has only one locale. + * @param spannable the spannable object to be updated. + * @param start the start index from which {@link LocaleSpan} is attached (inclusive). + * @param end the end index to which {@link LocaleSpan} is attached (exclusive). + * @param locale the locale to be attached to the specified range. + */ + @UsedForTesting + public static void updateLocaleSpan(final Spannable spannable, final int start, + final int end, final Locale locale) { + if (end < start) { + Log.e(TAG, "Invalid range: start=" + start + " end=" + end); + return; + } + if (!isLocaleSpanAvailable()) { + return; + } + // A brief summary of our strategy; + // 1. Enumerate all LocaleSpans between [start - 1, end + 1]. + // 2. For each LocaleSpan S: + // - Update the range of S so as not to cover [start, end] if S doesn't have the + // expected locale. + // - Mark S as "to be merged" if S has the expected locale. + // 3. Merge all the LocaleSpans that are marked as "to be merged" into one LocaleSpan. + // If no appropriate span is found, create a new one with newLocaleSpan method. + final int searchStart = Math.max(start - 1, 0); + final int searchEnd = Math.min(end + 1, spannable.length()); + // LocaleSpans found in the target range. See the step 1 in the above comment. + final Object[] existingLocaleSpans = spannable.getSpans(searchStart, searchEnd, + LOCALE_SPAN_TYPE); + // LocaleSpans that are marked as "to be merged". See the step 2 in the above comment. + final ArrayList<Object> existingLocaleSpansToBeMerged = new ArrayList<>(); + boolean isStartExclusive = true; + boolean isEndExclusive = true; + int newStart = start; + int newEnd = end; + for (final Object existingLocaleSpan : existingLocaleSpans) { + final Locale attachedLocale = getLocaleFromLocaleSpan(existingLocaleSpan); + if (!locale.equals(attachedLocale)) { + // This LocaleSpan does not have the expected locale. Update its range if it has + // an intersection with the range [start, end] (the first case of the step 2 in the + // above comment). + removeLocaleSpanFromRange(existingLocaleSpan, spannable, start, end); + continue; + } + final int spanStart = spannable.getSpanStart(existingLocaleSpan); + final int spanEnd = spannable.getSpanEnd(existingLocaleSpan); + if (spanEnd < spanStart) { + Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd); + continue; + } + if (spanEnd < start || end < spanStart) { + // No intersection found. + continue; + } + + // Here existingLocaleSpan has the expected locale and an intersection with the + // range [start, end] (the second case of the the step 2 in the above comment). + final int spanFlag = spannable.getSpanFlags(existingLocaleSpan); + if (spanStart < newStart) { + newStart = spanStart; + isStartExclusive = ((spanFlag & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) == + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (newEnd < spanEnd) { + newEnd = spanEnd; + isEndExclusive = ((spanFlag & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) == + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + existingLocaleSpansToBeMerged.add(existingLocaleSpan); + } + + int originalLocaleSpanFlag = 0; + Object localeSpan = null; + if (existingLocaleSpansToBeMerged.isEmpty()) { + // If there is no LocaleSpan that is marked as to be merged, create a new one. + localeSpan = newLocaleSpan(locale); + } else { + // Reuse the first LocaleSpan to avoid unnecessary object instantiation. + localeSpan = existingLocaleSpansToBeMerged.get(0); + originalLocaleSpanFlag = spannable.getSpanFlags(localeSpan); + // No need to keep other instances. + for (int i = 1; i < existingLocaleSpansToBeMerged.size(); ++i) { + spannable.removeSpan(existingLocaleSpansToBeMerged.get(i)); + } + } + final int localeSpanFlag = getSpanFlag(originalLocaleSpanFlag, isStartExclusive, + isEndExclusive); + spannable.setSpan(localeSpan, newStart, newEnd, localeSpanFlag); + } + + private static void removeLocaleSpanFromRange(final Object localeSpan, + final Spannable spannable, final int removeStart, final int removeEnd) { + if (!isLocaleSpanAvailable()) { + return; + } + final int spanStart = spannable.getSpanStart(localeSpan); + final int spanEnd = spannable.getSpanEnd(localeSpan); + if (spanStart > spanEnd) { + Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd); + return; + } + if (spanEnd < removeStart) { + // spanStart < spanEnd < removeStart < removeEnd + return; + } + if (removeEnd < spanStart) { + // spanStart < removeEnd < spanStart < spanEnd + return; + } + final int spanFlags = spannable.getSpanFlags(localeSpan); + if (spanStart < removeStart) { + if (removeEnd < spanEnd) { + // spanStart < removeStart < removeEnd < spanEnd + final Locale locale = getLocaleFromLocaleSpan(localeSpan); + spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags); + final Object attionalLocaleSpan = newLocaleSpan(locale); + spannable.setSpan(attionalLocaleSpan, removeEnd, spanEnd, spanFlags); + return; + } + // spanStart < removeStart < spanEnd <= removeEnd + spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags); + return; + } + if (removeEnd < spanEnd) { + // removeStart <= spanStart < removeEnd < spanEnd + spannable.setSpan(localeSpan, removeEnd, spanEnd, spanFlags); + return; + } + // removeStart <= spanStart < spanEnd < removeEnd + spannable.removeSpan(localeSpan); + } + + private static int getSpanFlag(final int originalFlag, + final boolean isStartExclusive, final boolean isEndExclusive) { + return (originalFlag & ~Spanned.SPAN_POINT_MARK_MASK) | + getSpanPointMarkFlag(isStartExclusive, isEndExclusive); + } + + private static int getSpanPointMarkFlag(final boolean isStartExclusive, + final boolean isEndExclusive) { + if (isStartExclusive) { + return isEndExclusive ? Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + : Spanned.SPAN_EXCLUSIVE_INCLUSIVE; + } + return isEndExclusive ? Spanned.SPAN_INCLUSIVE_EXCLUSIVE + : Spanned.SPAN_INCLUSIVE_INCLUSIVE; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/LooperCompatUtils.java b/java/src/org/kelar/inputmethod/compat/LooperCompatUtils.java new file mode 100644 index 000000000..a48f4c1ab --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/LooperCompatUtils.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.os.Looper; + +import java.lang.reflect.Method; + +/** + * Helper to call Looper#quitSafely, which was introduced in API + * level 18 (Build.VERSION_CODES.JELLY_BEAN_MR2). + * + * In unit tests, we create lots of instances of LatinIME, which means we need to clean up + * some Loopers lest we leak file descriptors. In normal use on a device though, this is never + * necessary (although it does not hurt). + */ +public final class LooperCompatUtils { + private static final Method METHOD_quitSafely = CompatUtils.getMethod( + Looper.class, "quitSafely"); + + public static void quitSafely(final Looper looper) { + if (null != METHOD_quitSafely) { + CompatUtils.invoke(looper, null /* default return value */, METHOD_quitSafely); + } else { + looper.quit(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/compat/NotificationCompatUtils.java b/java/src/org/kelar/inputmethod/compat/NotificationCompatUtils.java new file mode 100644 index 000000000..7797edacf --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/NotificationCompatUtils.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.app.Notification; +import android.os.Build; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +public class NotificationCompatUtils { + // Note that TextInfo.getCharSequence() is supposed to be available in API level 21 and later. + private static final Method METHOD_setColor = + CompatUtils.getMethod(Notification.Builder.class, "setColor", int.class); + private static final Method METHOD_setVisibility = + CompatUtils.getMethod(Notification.Builder.class, "setVisibility", int.class); + private static final Method METHOD_setCategory = + CompatUtils.getMethod(Notification.Builder.class, "setCategory", String.class); + private static final Method METHOD_setPriority = + CompatUtils.getMethod(Notification.Builder.class, "setPriority", int.class); + private static final Method METHOD_build = + CompatUtils.getMethod(Notification.Builder.class, "build"); + private static final Field FIELD_VISIBILITY_SECRET = + CompatUtils.getField(Notification.class, "VISIBILITY_SECRET"); + private static final int VISIBILITY_SECRET = null == FIELD_VISIBILITY_SECRET ? 0 + : (Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */, + FIELD_VISIBILITY_SECRET); + private static final Field FIELD_CATEGORY_RECOMMENDATION = + CompatUtils.getField(Notification.class, "CATEGORY_RECOMMENDATION"); + private static final String CATEGORY_RECOMMENDATION = null == FIELD_CATEGORY_RECOMMENDATION ? "" + : (String) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */, + FIELD_CATEGORY_RECOMMENDATION); + private static final Field FIELD_PRIORITY_LOW = + CompatUtils.getField(Notification.class, "PRIORITY_LOW"); + private static final int PRIORITY_LOW = null == FIELD_PRIORITY_LOW ? 0 + : (Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */, + FIELD_PRIORITY_LOW); + + private NotificationCompatUtils() { + // This class is non-instantiable. + } + + // Sets the accent color + public static void setColor(final Notification.Builder builder, final int color) { + CompatUtils.invoke(builder, null, METHOD_setColor, color); + } + + public static void setVisibilityToSecret(final Notification.Builder builder) { + CompatUtils.invoke(builder, null, METHOD_setVisibility, VISIBILITY_SECRET); + } + + public static void setCategoryToRecommendation(final Notification.Builder builder) { + CompatUtils.invoke(builder, null, METHOD_setCategory, CATEGORY_RECOMMENDATION); + } + + public static void setPriorityToLow(final Notification.Builder builder) { + CompatUtils.invoke(builder, null, METHOD_setPriority, PRIORITY_LOW); + } + + @SuppressWarnings("deprecation") + public static Notification build(final Notification.Builder builder) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + // #build was added in API level 16, JELLY_BEAN + return (Notification) CompatUtils.invoke(builder, null, METHOD_build); + } + // #getNotification was deprecated in API level 16, JELLY_BEAN + return builder.getNotification(); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/SettingsSecureCompatUtils.java b/java/src/org/kelar/inputmethod/compat/SettingsSecureCompatUtils.java new file mode 100644 index 000000000..2b47ee274 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/SettingsSecureCompatUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import java.lang.reflect.Field; + +public final class SettingsSecureCompatUtils { + // Note that Settings.Secure.ACCESSIBILITY_SPEAK_PASSWORD has been introduced + // in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1). + private static final Field FIELD_ACCESSIBILITY_SPEAK_PASSWORD = CompatUtils.getField( + android.provider.Settings.Secure.class, "ACCESSIBILITY_SPEAK_PASSWORD"); + + private SettingsSecureCompatUtils() { + // This class is non-instantiable. + } + + /** + * Whether to speak passwords while in accessibility mode. + */ + public static final String ACCESSIBILITY_SPEAK_PASSWORD = (String) CompatUtils.getFieldValue( + null /* receiver */, null /* defaultValue */, FIELD_ACCESSIBILITY_SPEAK_PASSWORD); +} diff --git a/java/src/org/kelar/inputmethod/compat/SuggestionSpanUtils.java b/java/src/org/kelar/inputmethod/compat/SuggestionSpanUtils.java new file mode 100644 index 000000000..080d4754b --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/SuggestionSpanUtils.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.SuggestionSpan; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SuggestionSpanUtils { + // Note that SuggestionSpan.FLAG_AUTO_CORRECTION has been introduced + // in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1). + private static final Field FIELD_FLAG_AUTO_CORRECTION = CompatUtils.getField( + SuggestionSpan.class, "FLAG_AUTO_CORRECTION"); + private static final Integer OBJ_FLAG_AUTO_CORRECTION = (Integer) CompatUtils.getFieldValue( + null /* receiver */, null /* defaultValue */, FIELD_FLAG_AUTO_CORRECTION); + + static { + if (DebugFlags.DEBUG_ENABLED) { + if (OBJ_FLAG_AUTO_CORRECTION == null) { + throw new RuntimeException("Field is accidentially null."); + } + } + } + + private SuggestionSpanUtils() { + // This utility class is not publicly instantiable. + } + + @UsedForTesting + public static CharSequence getTextWithAutoCorrectionIndicatorUnderline( + final Context context, final String text, @Nonnull final Locale locale) { + if (TextUtils.isEmpty(text) || OBJ_FLAG_AUTO_CORRECTION == null) { + return text; + } + final Spannable spannable = new SpannableString(text); + final SuggestionSpan suggestionSpan = new SuggestionSpan(context, locale, + new String[] {} /* suggestions */, OBJ_FLAG_AUTO_CORRECTION, null); + spannable.setSpan(suggestionSpan, 0, text.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING); + return spannable; + } + + @UsedForTesting + public static CharSequence getTextWithSuggestionSpan(final Context context, + final String pickedWord, final SuggestedWords suggestedWords, final Locale locale) { + if (TextUtils.isEmpty(pickedWord) || suggestedWords.isEmpty() + || suggestedWords.isPrediction() || suggestedWords.isPunctuationSuggestions()) { + return pickedWord; + } + + final ArrayList<String> suggestionsList = new ArrayList<>(); + for (int i = 0; i < suggestedWords.size(); ++i) { + if (suggestionsList.size() >= SuggestionSpan.SUGGESTIONS_MAX_SIZE) { + break; + } + final SuggestedWordInfo info = suggestedWords.getInfo(i); + if (info.isKindOf(SuggestedWordInfo.KIND_PREDICTION)) { + continue; + } + final String word = suggestedWords.getWord(i); + if (!TextUtils.equals(pickedWord, word)) { + suggestionsList.add(word.toString()); + } + } + final SuggestionSpan suggestionSpan = new SuggestionSpan(context, locale, + suggestionsList.toArray(new String[suggestionsList.size()]), 0 /* flags */, null); + final Spannable spannable = new SpannableString(pickedWord); + spannable.setSpan(suggestionSpan, 0, pickedWord.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + /** + * Returns first {@link Locale} found in the given array of {@link SuggestionSpan}. + * @param suggestionSpans the array of {@link SuggestionSpan} to be examined. + * @return the first {@link Locale} found in {@code suggestionSpans}. {@code null} when not + * found. + */ + @UsedForTesting + @Nullable + public static Locale findFirstLocaleFromSuggestionSpans( + final SuggestionSpan[] suggestionSpans) { + for (final SuggestionSpan suggestionSpan : suggestionSpans) { + final String localeString = suggestionSpan.getLocale(); + if (TextUtils.isEmpty(localeString)) { + continue; + } + return LocaleUtils.constructLocaleFromString(localeString); + } + return null; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/SuggestionsInfoCompatUtils.java b/java/src/org/kelar/inputmethod/compat/SuggestionsInfoCompatUtils.java new file mode 100644 index 000000000..fd7210726 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/SuggestionsInfoCompatUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.compat; + +import android.view.textservice.SuggestionsInfo; + +import java.lang.reflect.Field; + +public final class SuggestionsInfoCompatUtils { + // Note that SuggestionsInfo.RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS has been introduced + // in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1). + private static final Field FIELD_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS = + CompatUtils.getField(SuggestionsInfo.class, "RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS"); + private static final Integer OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS = + (Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */, + FIELD_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS); + private static final int RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS = + OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS != null + ? OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS : 0; + + private SuggestionsInfoCompatUtils() { + // This utility class is not publicly instantiable. + } + + /** + * Returns the flag value of the attributes of the suggestions that can be obtained by + * {@link SuggestionsInfo#getSuggestionsAttributes()}: this tells that the text service thinks + * the result suggestions include highly recommended ones. + */ + public static int getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS() { + return RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/TextInfoCompatUtils.java b/java/src/org/kelar/inputmethod/compat/TextInfoCompatUtils.java new file mode 100644 index 000000000..d5b97b75e --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/TextInfoCompatUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.view.textservice.TextInfo; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +@UsedForTesting +public final class TextInfoCompatUtils { + // Note that TextInfo.getCharSequence() is supposed to be available in API level 21 and later. + private static final Method TEXT_INFO_GET_CHAR_SEQUENCE = + CompatUtils.getMethod(TextInfo.class, "getCharSequence"); + private static final Constructor<?> TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE = + CompatUtils.getConstructor(TextInfo.class, CharSequence.class, int.class, int.class, + int.class, int.class); + + @UsedForTesting + public static boolean isCharSequenceSupported() { + return TEXT_INFO_GET_CHAR_SEQUENCE != null && + TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE != null; + } + + @UsedForTesting + public static TextInfo newInstance(CharSequence charSequence, int start, int end, int cookie, + int sequenceNumber) { + if (TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE != null) { + return (TextInfo) CompatUtils.newInstance(TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE, + charSequence, start, end, cookie, sequenceNumber); + } + return new TextInfo(charSequence.subSequence(start, end).toString(), cookie, + sequenceNumber); + } + + /** + * Returns the result of {@link TextInfo#getCharSequence()} when available. Otherwise returns + * the result of {@link TextInfo#getText()} as fall back. + * @param textInfo the instance for which {@link TextInfo#getCharSequence()} or + * {@link TextInfo#getText()} is called. + * @return the result of {@link TextInfo#getCharSequence()} when available. Otherwise returns + * the result of {@link TextInfo#getText()} as fall back. If {@code textInfo} is {@code null}, + * returns {@code null}. + */ + @UsedForTesting + public static CharSequence getCharSequenceOrString(final TextInfo textInfo) { + final CharSequence defaultValue = (textInfo == null ? null : textInfo.getText()); + return (CharSequence) CompatUtils.invoke(textInfo, defaultValue, + TEXT_INFO_GET_CHAR_SEQUENCE); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/TextViewCompatUtils.java b/java/src/org/kelar/inputmethod/compat/TextViewCompatUtils.java new file mode 100644 index 000000000..dfad0e3a8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/TextViewCompatUtils.java @@ -0,0 +1,44 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.graphics.drawable.Drawable; +import android.widget.TextView; + +import java.lang.reflect.Method; + +public final class TextViewCompatUtils { + // Note that TextView.setCompoundDrawablesRelativeWithIntrinsicBounds(Drawable,Drawable, + // Drawable,Drawable) has been introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1). + private static final Method METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds = + CompatUtils.getMethod(TextView.class, "setCompoundDrawablesRelativeWithIntrinsicBounds", + Drawable.class, Drawable.class, Drawable.class, Drawable.class); + + private TextViewCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static void setCompoundDrawablesRelativeWithIntrinsicBounds(final TextView textView, + final Drawable start, final Drawable top, final Drawable end, final Drawable bottom) { + if (METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds == null) { + textView.setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom); + return; + } + CompatUtils.invoke(textView, null, METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds, + start, top, end, bottom); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/UserDictionaryCompatUtils.java b/java/src/org/kelar/inputmethod/compat/UserDictionaryCompatUtils.java new file mode 100644 index 000000000..0a4635c86 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/UserDictionaryCompatUtils.java @@ -0,0 +1,49 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.provider.UserDictionary; + +import java.util.Locale; + +public final class UserDictionaryCompatUtils { + @SuppressWarnings("deprecation") + public static void addWord(final Context context, final String word, + final int freq, final String shortcut, final Locale locale) { + if (BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + addWordWithShortcut(context, word, freq, shortcut, locale); + return; + } + // Fall back to the pre-JellyBean method. + final Locale currentLocale = context.getResources().getConfiguration().locale; + final int localeType = currentLocale.equals(locale) + ? UserDictionary.Words.LOCALE_TYPE_CURRENT : UserDictionary.Words.LOCALE_TYPE_ALL; + UserDictionary.Words.addWord(context, word, freq, localeType); + } + + // {@link UserDictionary.Words#addWord(Context,String,int,String,Locale)} was introduced + // in API level 16 (Build.VERSION_CODES.JELLY_BEAN). + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private static void addWordWithShortcut(final Context context, final String word, + final int freq, final String shortcut, final Locale locale) { + UserDictionary.Words.addWord(context, word, freq, shortcut, locale); + } +} + diff --git a/java/src/org/kelar/inputmethod/compat/UserManagerCompatUtils.java b/java/src/org/kelar/inputmethod/compat/UserManagerCompatUtils.java new file mode 100644 index 000000000..f72e28726 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/UserManagerCompatUtils.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2016 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 org.kelar.inputmethod.compat; + +import android.content.Context; +import android.os.Build; +import android.os.UserManager; +import androidx.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.reflect.Method; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +/** + * A temporary solution until {@code UserManagerCompat.isUserUnlocked()} in the support-v4 library + * becomes publicly available. + */ +public final class UserManagerCompatUtils { + private static final Method METHOD_isUserUnlocked; + + static { + // We do not try to search the method in Android M and prior. + if (BuildCompatUtils.EFFECTIVE_SDK_INT <= Build.VERSION_CODES.M) { + METHOD_isUserUnlocked = null; + } else { + METHOD_isUserUnlocked = CompatUtils.getMethod(UserManager.class, "isUserUnlocked"); + } + } + + private UserManagerCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static final int LOCK_STATE_UNKNOWN = 0; + public static final int LOCK_STATE_UNLOCKED = 1; + public static final int LOCK_STATE_LOCKED = 2; + + @Retention(SOURCE) + @IntDef({LOCK_STATE_UNKNOWN, LOCK_STATE_UNLOCKED, LOCK_STATE_LOCKED}) + public @interface LockState {} + + /** + * Check if the calling user is running in an "unlocked" state. A user is unlocked only after + * they've entered their credentials (such as a lock pattern or PIN), and credential-encrypted + * private app data storage is available. + * @param context context from which {@link UserManager} should be obtained. + * @return One of {@link LockState}. + */ + @LockState + public static int getUserLockState(final Context context) { + if (METHOD_isUserUnlocked == null) { + return LOCK_STATE_UNKNOWN; + } + final UserManager userManager = context.getSystemService(UserManager.class); + if (userManager == null) { + return LOCK_STATE_UNKNOWN; + } + final Boolean result = + (Boolean) CompatUtils.invoke(userManager, null, METHOD_isUserUnlocked); + if (result == null) { + return LOCK_STATE_UNKNOWN; + } + return result ? LOCK_STATE_UNLOCKED : LOCK_STATE_LOCKED; + } +} diff --git a/java/src/org/kelar/inputmethod/compat/ViewCompatUtils.java b/java/src/org/kelar/inputmethod/compat/ViewCompatUtils.java new file mode 100644 index 000000000..9b91897e5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/ViewCompatUtils.java @@ -0,0 +1,70 @@ +/* + * 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 org.kelar.inputmethod.compat; + +import android.view.View; + +import java.lang.reflect.Method; + +// TODO: Use {@link androidx.core.view.ViewCompat} instead of this utility class. +// Currently {@link #getPaddingEnd(View)} and {@link #setPaddingRelative(View,int,int,int,int)} +// are missing from android-support-v4 static library in KitKat SDK. +public final class ViewCompatUtils { + // Note that View.getPaddingEnd(), View.setPaddingRelative(int,int,int,int) have been + // introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1). + private static final Method METHOD_getPaddingEnd = CompatUtils.getMethod( + View.class, "getPaddingEnd"); + private static final Method METHOD_setPaddingRelative = CompatUtils.getMethod( + View.class, "setPaddingRelative", + int.class, int.class, int.class, int.class); + // Note that View.setTextAlignment(int) has been introduced in API level 17. + private static final Method METHOD_setTextAlignment = CompatUtils.getMethod( + View.class, "setTextAlignment", int.class); + + private ViewCompatUtils() { + // This utility class is not publicly instantiable. + } + + public static int getPaddingEnd(final View view) { + if (METHOD_getPaddingEnd == null) { + return view.getPaddingRight(); + } + return (Integer)CompatUtils.invoke(view, 0, METHOD_getPaddingEnd); + } + + public static void setPaddingRelative(final View view, final int start, final int top, + final int end, final int bottom) { + if (METHOD_setPaddingRelative == null) { + view.setPadding(start, top, end, bottom); + return; + } + CompatUtils.invoke(view, null, METHOD_setPaddingRelative, start, top, end, bottom); + } + + // These TEXT_ALIGNMENT_* constants have been introduced in API 17. + public static final int TEXT_ALIGNMENT_INHERIT = 0; + public static final int TEXT_ALIGNMENT_GRAVITY = 1; + public static final int TEXT_ALIGNMENT_TEXT_START = 2; + public static final int TEXT_ALIGNMENT_TEXT_END = 3; + public static final int TEXT_ALIGNMENT_CENTER = 4; + public static final int TEXT_ALIGNMENT_VIEW_START = 5; + public static final int TEXT_ALIGNMENT_VIEW_END = 6; + + public static void setTextAlignment(final View view, final int textAlignment) { + CompatUtils.invoke(view, null, METHOD_setTextAlignment, textAlignment); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtils.java b/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtils.java new file mode 100644 index 000000000..64f4230ba --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtils.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.inputmethodservice.InputMethodService; +import android.os.Build; +import android.view.View; + +public class ViewOutlineProviderCompatUtils { + private ViewOutlineProviderCompatUtils() { + // This utility class is not publicly instantiable. + } + + public interface InsetsUpdater { + public void setInsets(final InputMethodService.Insets insets); + } + + private static final InsetsUpdater EMPTY_INSETS_UPDATER = new InsetsUpdater() { + @Override + public void setInsets(final InputMethodService.Insets insets) {} + }; + + public static InsetsUpdater setInsetsOutlineProvider(final View view) { + if (BuildCompatUtils.EFFECTIVE_SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return EMPTY_INSETS_UPDATER; + } + return ViewOutlineProviderCompatUtilsLXX.setInsetsOutlineProvider(view); + } +} diff --git a/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtilsLXX.java b/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtilsLXX.java new file mode 100644 index 000000000..8049d7d04 --- /dev/null +++ b/java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtilsLXX.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.compat; + +import android.annotation.TargetApi; +import android.graphics.Outline; +import android.inputmethodservice.InputMethodService; +import android.os.Build; +import android.view.View; +import android.view.ViewOutlineProvider; + +import org.kelar.inputmethod.compat.ViewOutlineProviderCompatUtils.InsetsUpdater; + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +class ViewOutlineProviderCompatUtilsLXX { + private ViewOutlineProviderCompatUtilsLXX() { + // This utility class is not publicly instantiable. + } + + static InsetsUpdater setInsetsOutlineProvider(final View view) { + final InsetsOutlineProvider provider = new InsetsOutlineProvider(view); + view.setOutlineProvider(provider); + return provider; + } + + private static class InsetsOutlineProvider extends ViewOutlineProvider + implements InsetsUpdater { + private final View mView; + private static final int NO_DATA = -1; + private int mLastVisibleTopInsets = NO_DATA; + + public InsetsOutlineProvider(final View view) { + mView = view; + view.setOutlineProvider(this); + } + + @Override + public void setInsets(final InputMethodService.Insets insets) { + final int visibleTopInsets = insets.visibleTopInsets; + if (mLastVisibleTopInsets != visibleTopInsets) { + mLastVisibleTopInsets = visibleTopInsets; + mView.invalidateOutline(); + } + } + + @Override + public void getOutline(final View view, final Outline outline) { + if (mLastVisibleTopInsets == NO_DATA) { + // Call default implementation. + ViewOutlineProvider.BACKGROUND.getOutline(view, outline); + return; + } + // TODO: Revisit this when floating/resize keyboard is supported. + outline.setRect( + view.getLeft(), mLastVisibleTopInsets, view.getRight(), view.getBottom()); + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java b/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java new file mode 100644 index 000000000..06bebc8da --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java @@ -0,0 +1,625 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.app.DownloadManager.Request; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.BinaryDictionaryFileDumper; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * Object representing an upgrade from one state to another. + * + * This implementation basically encapsulates a list of Runnable objects. In the future + * it may manage dependencies between them. Concretely, it does not use Runnable because the + * actions need an argument. + */ +/* + +The state of a word list follows the following scheme. + + | ^ + MakeAvailable | + | .------------Forget--------' + V | + STATUS_AVAILABLE <-------------------------. + | | +StartDownloadAction FinishDeleteAction + | | + V | +STATUS_DOWNLOADING EnableAction-- STATUS_DELETING + | | ^ +InstallAfterDownloadAction | | + | .---------------' StartDeleteAction + | | | + V V | + STATUS_INSTALLED <--EnableAction-- STATUS_DISABLED + --DisableAction--> + + It may also be possible that DisableAction or StartDeleteAction or + DownloadAction run when the file is still downloading. This cancels + the download and returns to STATUS_AVAILABLE. + Also, an UpdateDataAction may apply in any state. It does not affect + the state in any way (nor type, local filename, id or version) but + may update other attributes like description or remote filename. + + Forget is an DB maintenance action that removes the entry if it is not installed or disabled. + This happens when the word list information disappeared from the server, or when a new version + is available and we should forget about the old one. +*/ +public final class ActionBatch { + /** + * A piece of update. + * + * Action is basically like a Runnable that takes an argument. + */ + public interface Action { + /** + * Execute this action NOW. + * @param context the context to get system services, resources, databases + */ + void execute(final Context context); + } + + /** + * An action that starts downloading an available word list. + */ + public static final class StartDownloadAction implements Action { + static final String TAG = "DictionaryProvider:" + StartDownloadAction.class.getSimpleName(); + + private final String mClientId; + // The data to download. May not be null. + final WordListMetadata mWordList; + public StartDownloadAction(final String clientId, final WordListMetadata wordList) { + DebugLogUtils.l("New download action for client ", clientId, " : ", wordList); + mClientId = clientId; + mWordList = wordList; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "UpdateAction with a null parameter!"); + return; + } + DebugLogUtils.l("Downloading word list"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + if (MetadataDbHelper.STATUS_DOWNLOADING == status) { + // The word list is still downloading. Cancel the download and revert the + // word list status to "available". + manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN)); + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } else if (MetadataDbHelper.STATUS_AVAILABLE != status + && MetadataDbHelper.STATUS_RETRYING != status) { + // Should never happen + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + status + + " for an upgrade action. Fall back to download."); + } + // Download it. + DebugLogUtils.l("Upgrade word list, downloading", mWordList.mRemoteFilename); + + // This is an upgraded word list: we should download it. + // Adding a disambiguator to circumvent a bug in older versions of DownloadManager. + // DownloadManager also stupidly cuts the extension to replace with its own that it + // gets from the content-type. We need to circumvent this. + final String disambiguator = "#" + System.currentTimeMillis() + + ApplicationUtils.getVersionName(context) + ".dict"; + final Uri uri = Uri.parse(mWordList.mRemoteFilename + disambiguator); + final Request request = new Request(uri); + + final Resources res = context.getResources(); + request.setAllowedNetworkTypes(Request.NETWORK_WIFI | Request.NETWORK_MOBILE); + request.setTitle(mWordList.mDescription); + request.setNotificationVisibility(Request.VISIBILITY_HIDDEN); + request.setVisibleInDownloadsUi( + res.getBoolean(R.bool.dict_downloads_visible_in_download_UI)); + + final long downloadId = UpdateHandler.registerDownloadRequest(manager, request, db, + mWordList.mId, mWordList.mVersion); + Log.i(TAG, String.format("Starting the dictionary download with version:" + + " %d and Url: %s", mWordList.mVersion, uri)); + DebugLogUtils.l("Starting download of", uri, "with id", downloadId); + PrivateLog.log("Starting download of " + uri + ", id : " + downloadId); + } + } + + /** + * An action that updates the database to reflect the status of a newly installed word list. + */ + public static final class InstallAfterDownloadAction implements Action { + static final String TAG = "DictionaryProvider:" + + InstallAfterDownloadAction.class.getSimpleName(); + private final String mClientId; + // The state to upgrade from. May not be null. + final ContentValues mWordListValues; + + public InstallAfterDownloadAction(final String clientId, + final ContentValues wordListValues) { + DebugLogUtils.l("New InstallAfterDownloadAction for client ", clientId, " : ", + wordListValues); + mClientId = clientId; + mWordListValues = wordListValues; + } + + @Override + public void execute(final Context context) { + if (null == mWordListValues) { + Log.e(TAG, "InstallAfterDownloadAction with a null parameter!"); + return; + } + final int status = mWordListValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DOWNLOADING != status) { + final String id = mWordListValues.getAsString(MetadataDbHelper.WORDLISTID_COLUMN); + Log.e(TAG, "Unexpected state of the word list '" + id + "' : " + status + + " for an InstallAfterDownload action. Bailing out."); + return; + } + + DebugLogUtils.l("Setting word list as installed"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues); + + // Install the downloaded file by un-compressing and moving it to the staging + // directory. Ideally, we should do this before updating the DB, but the + // installDictToStagingFromContentProvider() relies on the db being updated. + final String localeString = mWordListValues.getAsString(MetadataDbHelper.LOCALE_COLUMN); + BinaryDictionaryFileDumper.installDictToStagingFromContentProvider( + LocaleUtils.constructLocaleFromString(localeString), context, false); + } + } + + /** + * An action that enables an existing word list. + */ + public static final class EnableAction implements Action { + static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName(); + private final String mClientId; + // The state to upgrade from. May not be null. + final WordListMetadata mWordList; + + public EnableAction(final String clientId, final WordListMetadata wordList) { + DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList); + mClientId = clientId; + mWordList = wordList; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { + Log.e(TAG, "EnableAction with a null parameter!"); + return; + } + DebugLogUtils.l("Enabling word list"); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DISABLED != status + && MetadataDbHelper.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status + + " for an enable action. Cancelling"); + return; + } + MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion); + } + } + + /** + * An action that disables a word list. + */ + public static final class DisableAction implements Action { + static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName(); + private final String mClientId; + // The word list to disable. May not be null. + final WordListMetadata mWordList; + public DisableAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "DisableAction with a null word list!"); + return; + } + DebugLogUtils.l("Disabling word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_INSTALLED == status) { + // Disabling an installed word list + MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion); + } else { + if (MetadataDbHelper.STATUS_DOWNLOADING != status) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + + status + " for a disable action. Fall back to marking as available."); + } + // The word list is still downloading. Cancel the download and revert the + // word list status to "available". + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN)); + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } + } + } + + /** + * An action that makes a word list available. + */ + public static final class MakeAvailableAction implements Action { + static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName(); + private final String mClientId; + // The word list to make available. May not be null. + final WordListMetadata mWordList; + public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MakeAvailableAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + if (null != MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a makeavailable action. Marking as available anyway."); + } + DebugLogUtils.l("Making word list available : " + mWordList); + // If mLocalFilename is null, then it's a remote file that hasn't been downloaded + // yet, so we set the local filename to the empty string. + final ContentValues values = MetadataDbHelper.makeContentValues(0, + MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename, + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Insert 'available' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); + } + } + + /** + * An action that marks a word list as pre-installed. + * + * This is almost the same as MakeAvailableAction, as it only inserts a line with parameters + * received from outside. + * Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file + * but from the client directly; it marks a word list as being "installed" and not "available". + * It also explicitly sets the filename to the empty string, so that we don't try to open + * it on our side. + */ + public static final class MarkPreInstalledAction implements Action { + static final String TAG = "DictionaryProvider:" + + MarkPreInstalledAction.class.getSimpleName(); + private final String mClientId; + // The word list to mark pre-installed. May not be null. + final WordListMetadata mWordList; + public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "MarkPreInstalledAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + if (null != MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion)) { + Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' " + + " for a markpreinstalled action. Marking as preinstalled anyway."); + } + DebugLogUtils.l("Marking word list preinstalled : " + mWordList); + // This word list is pre-installed : we don't have its file. We should reset + // the local file name to the empty string so that we don't try to open it + // accidentally. The remote filename may be set by the application if it so wishes. + final ContentValues values = MetadataDbHelper.makeContentValues(0, + MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED, + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + TextUtils.isEmpty(mWordList.mLocalFilename) ? "" : mWordList.mLocalFilename, + mWordList.mRemoteFilename, mWordList.mLastUpdate, + mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount, + mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values); + } + } + + /** + * An action that updates information about a word list - description, locale etc + */ + public static final class UpdateDataAction implements Action { + static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName(); + private final String mClientId; + final WordListMetadata mWordList; + public UpdateDataAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "UpdateDataAction with a null word list!"); + return; + } + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == oldValues) { + Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out."); + return; + } + DebugLogUtils.l("Updating data about a word list : " + mWordList); + final ContentValues values = MetadataDbHelper.makeContentValues( + oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN), + oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN), + mWordList.mId, mWordList.mLocale, mWordList.mDescription, + oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN), + mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum, + mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize, + mWordList.mVersion, mWordList.mFormatVersion); + PrivateLog.log("Updating record for " + mWordList.mDescription + + " and locale " + mWordList.mLocale); + db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } + } + + /** + * An action that deletes the metadata about a word list if possible. + * + * This is triggered when a specific word list disappeared from the server, or when a fresher + * word list is available and the old one was not installed. + * If the word list has not been installed, it's possible to delete its associated metadata. + * Otherwise, the settings are retained so that the user can still administrate it. + */ + public static final class ForgetAction implements Action { + static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName(); + private final String mClientId; + // The word list to remove. May not be null. + final WordListMetadata mWordList; + final boolean mHasNewerVersion; + public ForgetAction(final String clientId, final WordListMetadata wordlist, + final boolean hasNewerVersion) { + DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + mHasNewerVersion = hasNewerVersion; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "TryRemoveAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to remove word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) { + // If we have a newer version of this word list, we should be here ONLY if it was + // not installed - else we should be upgrading it. + Log.e(TAG, "Unexpected status for forgetting a word list info : " + status + + ", removing URL to prevent re-download"); + } + if (MetadataDbHelper.STATUS_INSTALLED == status + || MetadataDbHelper.STATUS_DISABLED == status + || MetadataDbHelper.STATUS_DELETING == status) { + // If it is installed or disabled, we need to mark it as deleted so that LatinIME + // will remove it next time it enquires for dictionaries. + // If it is deleting and we don't have a new version, then we have to wait until + // LatinIME actually has deleted it before we can remove its metadata. + // In both cases, remove the URI from the database since it is not supposed to + // be accessible any more. + values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, ""); + values.put(MetadataDbHelper.STATUS_COLUMN, MetadataDbHelper.STATUS_DELETING); + db.update(MetadataDbHelper.METADATA_TABLE_NAME, values, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } else { + // If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry. + db.delete(MetadataDbHelper.METADATA_TABLE_NAME, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } + } + } + + /** + * An action that sets the word list for deletion as soon as possible. + * + * This is triggered when the user requests deletion of a word list. This will mark it as + * deleted in the database, and fire an intent for Kelar Keyboard to take notice and + * reload its dictionaries right away if it is up. If it is not up now, then it will + * delete the actual file the next time it gets up. + * A file marked as deleted causes the content provider to supply a zero-sized file to + * Kelar Keyboard, which will overwrite any existing file and provide no words for this + * word list. This is not exactly a "deletion", since there is an actual file which takes up + * a few bytes on the disk, but this allows to override a default dictionary with an empty + * dictionary. This way, there is no need for the user to make a distinction between + * dictionaries installed by default and add-on dictionaries. + */ + public static final class StartDeleteAction implements Action { + static final String TAG = "DictionaryProvider:" + StartDeleteAction.class.getSimpleName(); + private final String mClientId; + // The word list to delete. May not be null. + final WordListMetadata mWordList; + public StartDeleteAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "StartDeleteAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to delete word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DISABLED != status) { + Log.e(TAG, "Unexpected status for deleting a word list info : " + status); + } + MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion); + } + } + + /** + * An action that validates a word list as deleted. + * + * This will restore the word list as available if it still is, or remove the entry if + * it is not any more. + */ + public static final class FinishDeleteAction implements Action { + static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName(); + private final String mClientId; + // The word list to delete. May not be null. + final WordListMetadata mWordList; + public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) { + DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist); + mClientId = clientId; + mWordList = wordlist; + } + + @Override + public void execute(final Context context) { + if (null == mWordList) { // This should never happen + Log.e(TAG, "FinishDeleteAction with a null word list!"); + return; + } + DebugLogUtils.l("Trying to delete word list : " + mWordList); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId); + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + mWordList.mId, mWordList.mVersion); + if (null == values) { + Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling."); + return; + } + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DELETING != status) { + Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status); + } + final String remoteFilename = + values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + // If there isn't a remote filename any more, then we don't know where to get the file + // from any more, so we remove the entry entirely. As a matter of fact, if the file was + // marked DELETING but disappeared from the metadata on the server, it ended up + // this way. + if (TextUtils.isEmpty(remoteFilename)) { + db.delete(MetadataDbHelper.METADATA_TABLE_NAME, + MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND " + + MetadataDbHelper.VERSION_COLUMN + " = ?", + new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) }); + } else { + MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion); + } + } + } + + // An action batch consists of an ordered queue of Actions that can execute. + private final Queue<Action> mActions; + + public ActionBatch() { + mActions = new LinkedList<>(); + } + + public void add(final Action a) { + mActions.add(a); + } + + /** + * Append all the actions of another action batch. + * @param that the upgrade to merge into this one. + */ + public void append(final ActionBatch that) { + for (final Action a : that.mActions) { + add(a); + } + } + + /** + * Execute this batch. + * + * @param context the context for getting resources, databases, system services. + * @param reporter a Reporter to send errors to. + */ + public void execute(final Context context, final ProblemReporter reporter) { + DebugLogUtils.l("Executing a batch of actions"); + Queue<Action> remainingActions = mActions; + while (!remainingActions.isEmpty()) { + final Action a = remainingActions.poll(); + try { + a.execute(context); + } catch (Exception e) { + if (null != reporter) + reporter.report(e); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java b/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java new file mode 100644 index 000000000..dd81acfaf --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import java.io.File; + +/** + * Immutable class to hold the address of an asset. + * As opposed to a normal file, an asset is usually represented as a contiguous byte array in + * the package file. Open it correctly thus requires the name of the package it is in, but + * also the offset in the file and the length of this data. This class encapsulates these three. + */ +final class AssetFileAddress { + public final String mFilename; + public final long mOffset; + public final long mLength; + + public AssetFileAddress(final String filename, final long offset, final long length) { + mFilename = filename; + mOffset = offset; + mLength = length; + } + + /** + * Makes an AssetFileAddress. This may return null. + * + * @param filename the filename. + * @return the address, or null if the file does not exist or the parameters are not valid. + */ + 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()); + } + + /** + * Makes an AssetFileAddress. This may return null. + * + * @param filename the filename. + * @param offset the offset. + * @param length the length. + * @return the address, or null if the file does not exist or the parameters are not valid. + */ + public static AssetFileAddress makeFromFileNameAndOffset(final String filename, + final long offset, final long length) { + if (null == filename) return null; + final File f = new File(filename); + if (!f.isFile()) return null; + return new AssetFileAddress(filename, offset, length); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java b/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java new file mode 100644 index 000000000..de884d10a --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +/** + * Exception thrown when the metadata for the dictionary does not comply to a known format. + */ +public final class BadFormatException extends Exception { + public BadFormatException() { + super(); + } + + public BadFormatException(final String message) { + super(message); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java b/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java new file mode 100644 index 000000000..46692559e --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java @@ -0,0 +1,170 @@ +/** + * 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 org.kelar.inputmethod.dictionarypack; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewPropertyAnimator; +import android.widget.Button; +import android.widget.FrameLayout; + +import org.kelar.inputmethod.latin.R; + +/** + * A view that handles buttons inside it according to a status. + */ +public class ButtonSwitcher extends FrameLayout { + public static final int NOT_INITIALIZED = -1; + public static final int STATUS_NO_BUTTON = 0; + public static final int STATUS_INSTALL = 1; + public static final int STATUS_CANCEL = 2; + public static final int STATUS_DELETE = 3; + // One of the above + private int mStatus = NOT_INITIALIZED; + private int mAnimateToStatus = NOT_INITIALIZED; + + // Animation directions + public static final int ANIMATION_IN = 1; + public static final int ANIMATION_OUT = 2; + + private Button mInstallButton; + private Button mCancelButton; + private Button mDeleteButton; + private DictionaryListInterfaceState mInterfaceState; + private OnClickListener mOnClickListener; + + public ButtonSwitcher(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ButtonSwitcher(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public void reset(final DictionaryListInterfaceState interfaceState) { + mStatus = NOT_INITIALIZED; + mAnimateToStatus = NOT_INITIALIZED; + mInterfaceState = interfaceState; + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + super.onLayout(changed, left, top, right, bottom); + mInstallButton = (Button)findViewById(R.id.dict_install_button); + mCancelButton = (Button)findViewById(R.id.dict_cancel_button); + mDeleteButton = (Button)findViewById(R.id.dict_delete_button); + setInternalOnClickListener(mOnClickListener); + setButtonPositionWithoutAnimation(mStatus); + if (mAnimateToStatus != NOT_INITIALIZED) { + // We have been asked to animate before we were ready, so we took a note of it. + // We are now ready: launch the animation. + animateButtonPosition(mStatus, mAnimateToStatus); + mStatus = mAnimateToStatus; + mAnimateToStatus = NOT_INITIALIZED; + } + } + + private Button getButton(final int status) { + switch(status) { + case STATUS_INSTALL: + return mInstallButton; + case STATUS_CANCEL: + return mCancelButton; + case STATUS_DELETE: + return mDeleteButton; + default: + return null; + } + } + + public void setStatusAndUpdateVisuals(final int status) { + if (mStatus == NOT_INITIALIZED) { + setButtonPositionWithoutAnimation(status); + mStatus = status; + } else { + if (null == mInstallButton) { + // We may come here before we have been layout. In this case we don't know our + // size yet so we can't start animations so we need to remember what animation to + // start once layout has gone through. + mAnimateToStatus = status; + } else { + animateButtonPosition(mStatus, status); + mStatus = status; + } + } + } + + private void setButtonPositionWithoutAnimation(final int status) { + // This may be called by setStatus() before the layout has come yet. + if (null == mInstallButton) return; + final int width = getWidth(); + // Set to out of the screen if that's not the currently displayed status + mInstallButton.setTranslationX(STATUS_INSTALL == status ? 0 : width); + mCancelButton.setTranslationX(STATUS_CANCEL == status ? 0 : width); + mDeleteButton.setTranslationX(STATUS_DELETE == status ? 0 : width); + } + + // The helper method for {@link AnimatorListenerAdapter}. + void animateButtonIfStatusIsEqual(final View newButton, final int newStatus) { + if (newStatus != mStatus) return; + animateButton(newButton, ANIMATION_IN); + } + + private void animateButtonPosition(final int oldStatus, final int newStatus) { + final View oldButton = getButton(oldStatus); + final View newButton = getButton(newStatus); + if (null != oldButton && null != newButton) { + // Transition between two buttons : animate out, then in + animateButton(oldButton, ANIMATION_OUT).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + animateButtonIfStatusIsEqual(newButton, newStatus); + } + }); + } else if (null != oldButton) { + animateButton(oldButton, ANIMATION_OUT); + } else if (null != newButton) { + animateButton(newButton, ANIMATION_IN); + } + } + + public void setInternalOnClickListener(final OnClickListener listener) { + mOnClickListener = listener; + if (null != mInstallButton) { + // Already laid out : do it now + mInstallButton.setOnClickListener(mOnClickListener); + mCancelButton.setOnClickListener(mOnClickListener); + mDeleteButton.setOnClickListener(mOnClickListener); + } + } + + private ViewPropertyAnimator animateButton(final View button, final int direction) { + final float outerX = getWidth(); + final float innerX = button.getX() - button.getTranslationX(); + mInterfaceState.removeFromCache((View)getParent()); + if (ANIMATION_IN == direction) { + button.setClickable(true); + return button.animate().translationX(0); + } + button.setClickable(false); + return button.animate().translationX(outerX - innerX); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java b/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java new file mode 100644 index 000000000..e4676b186 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.Context; +import android.content.SharedPreferences; + +public final class CommonPreferences { + private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs"; + + public static SharedPreferences getCommonPreferences(final Context context) { + return context.getSharedPreferences(COMMON_PREFERENCES_NAME, 0); + } + + public static void enable(final SharedPreferences pref, final String id) { + final SharedPreferences.Editor editor = pref.edit(); + editor.putBoolean(id, true); + editor.apply(); + } + + public static void disable(final SharedPreferences pref, final String id) { + final SharedPreferences.Editor editor = pref.edit(); + editor.putBoolean(id, false); + editor.apply(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java b/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java new file mode 100644 index 000000000..aa55b4fe2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java @@ -0,0 +1,36 @@ +/* + * 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 org.kelar.inputmethod.dictionarypack; + +import android.app.DownloadManager; + +/** + * Struct class to encapsulate the result of a completed download. + */ +public class CompletedDownloadInfo { + final String mUri; + final long mDownloadId; + final int mStatus; + public CompletedDownloadInfo(final String uri, final long downloadId, final int status) { + mUri = uri; + mDownloadId = downloadId; + mStatus = status; + } + public boolean wasSuccessful() { + return DownloadManager.STATUS_SUCCESSFUL == mStatus; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java new file mode 100644 index 000000000..f0a591eca --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java @@ -0,0 +1,173 @@ +/** + * 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 org.kelar.inputmethod.dictionarypack; + +import android.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Handler; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.ProgressBar; + +public class DictionaryDownloadProgressBar extends ProgressBar { + private static final String TAG = DictionaryDownloadProgressBar.class.getSimpleName(); + private static final int NOT_A_DOWNLOADMANAGER_PENDING_ID = 0; + + private String mClientId; + private String mWordlistId; + private boolean mIsCurrentlyAttachedToWindow = false; + private Thread mReporterThread = null; + + public DictionaryDownloadProgressBar(final Context context) { + super(context); + } + + public DictionaryDownloadProgressBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public void setIds(final String clientId, final String wordlistId) { + mClientId = clientId; + mWordlistId = wordlistId; + } + + static private int getDownloadManagerPendingIdFromWordlistId(final Context context, + final String clientId, final String wordlistId) { + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + final ContentValues wordlistValues = + MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId); + if (null == wordlistValues) { + // We don't know anything about a word list with this id. Bug? This should never + // happen, but still return to prevent a crash. + Log.e(TAG, "Unexpected word list ID: " + wordlistId); + return NOT_A_DOWNLOADMANAGER_PENDING_ID; + } + return wordlistValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN); + } + + /* + * This method will stop any running updater thread for this progress bar and create and run + * a new one only if the progress bar is visible. + * Hence, as a result of calling this method, the progress bar will have an updater thread + * running if and only if the progress bar is visible. + */ + private void updateReporterThreadRunningStatusAccordingToVisibility() { + if (null != mReporterThread) mReporterThread.interrupt(); + if (mIsCurrentlyAttachedToWindow && View.VISIBLE == getVisibility()) { + final int downloadManagerPendingId = + getDownloadManagerPendingIdFromWordlistId(getContext(), mClientId, mWordlistId); + if (NOT_A_DOWNLOADMANAGER_PENDING_ID == downloadManagerPendingId) { + // Can't get the ID. This is never supposed to happen, but still clear the updater + // thread and return to avoid a crash. + mReporterThread = null; + return; + } + final UpdaterThread updaterThread = + new UpdaterThread(getContext(), downloadManagerPendingId); + updaterThread.start(); + mReporterThread = updaterThread; + } else { + // We're not going to restart the thread anyway, so we may as well garbage collect it. + mReporterThread = null; + } + } + + @Override + protected void onAttachedToWindow() { + mIsCurrentlyAttachedToWindow = true; + updateReporterThreadRunningStatusAccordingToVisibility(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mIsCurrentlyAttachedToWindow = false; + updateReporterThreadRunningStatusAccordingToVisibility(); + } + + private class UpdaterThread extends Thread { + private final static int REPORT_PERIOD = 150; // how often to report progress, in ms + final DownloadManagerWrapper mDownloadManagerWrapper; + final int mId; + public UpdaterThread(final Context context, final int id) { + super(); + mDownloadManagerWrapper = new DownloadManagerWrapper(context); + mId = id; + } + @Override + public void run() { + try { + final UpdateHelper updateHelper = new UpdateHelper(); + final Query query = new Query().setFilterById(mId); + setIndeterminate(true); + while (!isInterrupted()) { + final Cursor cursor = mDownloadManagerWrapper.query(query); + if (null == cursor) { + // Can't contact DownloadManager: this should never happen. + return; + } + try { + if (cursor.moveToNext()) { + final int columnBytesDownloadedSoFar = cursor.getColumnIndex( + DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR); + final int bytesDownloadedSoFar = + cursor.getInt(columnBytesDownloadedSoFar); + updateHelper.setProgressFromAnotherThread(bytesDownloadedSoFar); + } else { + // Download has finished and DownloadManager has already been asked to + // clean up the db entry. + updateHelper.setProgressFromAnotherThread(getMax()); + return; + } + } finally { + cursor.close(); + } + Thread.sleep(REPORT_PERIOD); + } + } catch (InterruptedException e) { + // Do nothing and terminate normally. + } + } + + class UpdateHelper implements Runnable { + private int mProgress; + @Override + public void run() { + setIndeterminate(false); + setProgress(mProgress); + } + public void setProgressFromAnotherThread(final int progress) { + if (mProgress != progress) { + mProgress = progress; + // For some unknown reason, setProgress just does not work from a separate + // thread, although the code in ProgressBar looks like it should. Thus, we + // resort to a runnable posted to the handler of the view. + final Handler handler = getHandler(); + // It's possible to come here before this view has been laid out. If so, + // just ignore the call - it will be updated again later. + if (null == handler) return; + handler.post(this); + } + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java new file mode 100644 index 000000000..3e469bd2c --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java @@ -0,0 +1,85 @@ +/** + * 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 org.kelar.inputmethod.dictionarypack; + +import android.view.View; + +import java.util.ArrayList; +import java.util.HashMap; + +/** + * Helper class to maintain the interface state of word list preferences. + * + * This is necessary because the views are created on-demand by calling code. There are many + * situations where views are renewed with little relation with user interaction. For example, + * when scrolling, the view is reused so it doesn't keep its state, which means we need to keep + * it separately. Also whenever the underlying dictionary list undergoes a change (for example, + * update the metadata, or finish downloading) the whole list has to be thrown out and recreated + * in case some dictionaries appeared, disappeared, changed states etc. + */ +public class DictionaryListInterfaceState { + static class State { + public boolean mOpen = false; + public int mStatus = MetadataDbHelper.STATUS_UNKNOWN; + } + + private HashMap<String, State> mWordlistToState = new HashMap<>(); + private ArrayList<View> mViewCache = new ArrayList<>(); + + public boolean isOpen(final String wordlistId) { + final State state = mWordlistToState.get(wordlistId); + if (null == state) return false; + return state.mOpen; + } + + public int getStatus(final String wordlistId) { + final State state = mWordlistToState.get(wordlistId); + if (null == state) return MetadataDbHelper.STATUS_UNKNOWN; + return state.mStatus; + } + + public void setOpen(final String wordlistId, final int status) { + final State newState; + final State state = mWordlistToState.get(wordlistId); + newState = null == state ? new State() : state; + newState.mOpen = true; + newState.mStatus = status; + mWordlistToState.put(wordlistId, newState); + } + + public void closeAll() { + for (final State state : mWordlistToState.values()) { + state.mOpen = false; + } + } + + public View findFirstOrphanedView() { + for (final View v : mViewCache) { + if (null == v.getParent()) return v; + } + return null; + } + + public View addToCacheAndReturnView(final View view) { + mViewCache.add(view); + return view; + } + + public void removeFromCache(final View view) { + mViewCache.remove(view); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java new file mode 100644 index 000000000..6f9b5be7d --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java @@ -0,0 +1,72 @@ +/* + * 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 org.kelar.inputmethod.dictionarypack; + +/** + * A class to group constants for dictionary pack usage. + * + * This class only defines constants. It should not make any references to outside code as far as + * possible, as it's used to separate cleanly the keyboard code from the dictionary pack code; this + * is needed in particular to cleanly compile regression tests. + */ +public class DictionaryPackConstants { + /** + * The root domain for the dictionary pack, upon which authorities and actions will append + * their own distinctive strings. + */ + private static final String DICTIONARY_DOMAIN = "org.kelar.inputmethod.dictionarypack.aosp"; + + /** + * Authority for the ContentProvider protocol. + */ + // TODO: find some way to factorize this string with the one in the resources + public static final String AUTHORITY = DICTIONARY_DOMAIN; + + /** + * The action of the intent for publishing that new dictionary data is available. + */ + // TODO: make this different across different packages. A suggested course of action is + // to use the package name inside this string. + // NOTE: The appended string should be uppercase like all other actions, but it's not for + // historical reasons. + public static final String NEW_DICTIONARY_INTENT_ACTION = DICTIONARY_DOMAIN + ".newdict"; + + /** + * The action of the intent sent by the dictionary pack to ask for a client to make + * itself known. This is used when the settings activity is brought up for a client the + * dictionary pack does not know about. + */ + public static final String UNKNOWN_DICTIONARY_PROVIDER_CLIENT = DICTIONARY_DOMAIN + + ".UNKNOWN_CLIENT"; + + // In the above intents, the name of the string extra that contains the name of the client + // we want information about. + public static final String DICTIONARY_PROVIDER_CLIENT_EXTRA = "client"; + + /** + * The action of the intent to tell the dictionary provider to update now. + */ + public static final String UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN + + ".UPDATE_NOW"; + + /** + * The intent action to inform the dictionary provider to initialize the db + * and update now. + */ + public static final String INIT_AND_UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN + + ".INIT_AND_UPDATE_NOW"; +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java new file mode 100644 index 000000000..fb3a4a391 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java @@ -0,0 +1,541 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.content.res.AssetFileDescriptor; +import android.database.AbstractCursor; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; + +/** + * Provider for dictionaries. + * + * This class is a ContentProvider exposing all available dictionary data as managed by + * the dictionary pack. + */ +public final class DictionaryProvider extends ContentProvider { + private static final String TAG = DictionaryProvider.class.getSimpleName(); + public static final boolean DEBUG = false; + + public static final Uri CONTENT_URI = + Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY); + private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; + private static final String QUERY_PARAMETER_TRUE = "true"; + private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; + private static final String QUERY_PARAMETER_FAILURE = "failure"; + public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol"; + private static final int NO_MATCH = 0; + private static final int DICTIONARY_V1_WHOLE_LIST = 1; + private static final int DICTIONARY_V1_DICT_INFO = 2; + private static final int DICTIONARY_V2_METADATA = 3; + private static final int DICTIONARY_V2_WHOLE_LIST = 4; + private static final int DICTIONARY_V2_DICT_INFO = 5; + private static final int DICTIONARY_V2_DATAFILE = 6; + private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH); + private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH); + static + { + sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST); + sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO); + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata", + DICTIONARY_V2_METADATA); + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST); + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*", + DICTIONARY_V2_DICT_INFO); + sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*", + DICTIONARY_V2_DATAFILE); + } + + // MIME types for dictionary and dictionary list, as required by ContentProvider contract. + public static final String DICT_LIST_MIME_TYPE = + "vnd.android.cursor.item/vnd.google.dictionarylist"; + public static final String DICT_DATAFILE_MIME_TYPE = + "vnd.android.cursor.item/vnd.google.dictionary"; + + public static final String ID_CATEGORY_SEPARATOR = ":"; + + private static final class WordListInfo { + public final String mId; + public final String mLocale; + public final String mRawChecksum; + public final int mMatchLevel; + public WordListInfo(final String id, final String locale, final String rawChecksum, + final int matchLevel) { + mId = id; + mLocale = locale; + mRawChecksum = rawChecksum; + mMatchLevel = matchLevel; + } + } + + /** + * A cursor for returning a list of file ids from a List of strings. + * + * This simulates only the necessary methods. It has no error handling to speak of, + * and does not support everything a database does, only a few select necessary methods. + */ + private static final class ResourcePathCursor extends AbstractCursor { + + // Column names for the cursor returned by this content provider. + static private final String[] columnNames = { MetadataDbHelper.WORDLISTID_COLUMN, + MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN }; + + // The list of word lists served by this provider that match the client request. + final WordListInfo[] mWordLists; + // Note : the cursor also uses mPos, which is defined in AbstractCursor. + + public ResourcePathCursor(final Collection<WordListInfo> wordLists) { + // Allocating a 0-size WordListInfo here allows the toArray() method + // to ensure we have a strongly-typed array. It's thrown out. That's + // what the documentation of #toArray says to do in order to get a + // new strongly typed array of the correct size. + mWordLists = wordLists.toArray(new WordListInfo[0]); + mPos = 0; + } + + @Override + public String[] getColumnNames() { + return columnNames; + } + + @Override + public int getCount() { + return mWordLists.length; + } + + @Override public double getDouble(int column) { return 0; } + @Override public float getFloat(int column) { return 0; } + @Override public int getInt(int column) { return 0; } + @Override public short getShort(int column) { return 0; } + @Override public long getLong(int column) { return 0; } + + @Override public String getString(final int column) { + switch (column) { + case 0: return mWordLists[mPos].mId; + case 1: return mWordLists[mPos].mLocale; + case 2: return mWordLists[mPos].mRawChecksum; + default : return null; + } + } + + @Override + public boolean isNull(final int column) { + if (mPos >= mWordLists.length) return true; + return column != 0; + } + } + + @Override + public boolean onCreate() { + return true; + } + + private static int matchUri(final Uri uri) { + int protocolVersion = 1; + final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); + if ("2".equals(protocolVersionArg)) protocolVersion = 2; + switch (protocolVersion) { + case 1: return sUriMatcherV1.match(uri); + case 2: return sUriMatcherV2.match(uri); + default: return NO_MATCH; + } + } + + private static String getClientId(final Uri uri) { + int protocolVersion = 1; + final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION); + if ("2".equals(protocolVersionArg)) protocolVersion = 2; + switch (protocolVersion) { + case 1: return null; // In protocol 1, the client ID is always null. + case 2: return uri.getPathSegments().get(0); + default: return null; + } + } + + /** + * Returns the MIME type of the content associated with an Uri + * + * @see android.content.ContentProvider#getType(android.net.Uri) + * + * @param uri the URI of the content the type of which should be returned. + * @return the MIME type, or null if the URL is not recognized. + */ + @Override + public String getType(final Uri uri) { + PrivateLog.log("Asked for type of : " + uri); + final int match = matchUri(uri); + switch (match) { + case NO_MATCH: return null; + case DICTIONARY_V1_WHOLE_LIST: + case DICTIONARY_V1_DICT_INFO: + case DICTIONARY_V2_WHOLE_LIST: + case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE; + case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE; + default: return null; + } + } + + /** + * Query the provider for dictionary files. + * + * This version dispatches the query according to the protocol version found in the + * ?protocol= query parameter. If absent or not well-formed, it defaults to 1. + * @see android.content.ContentProvider#query(Uri, String[], String, String[], String) + * + * @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format) + * @param projection ignored. All columns are always returned. + * @param selection ignored. + * @param selectionArgs ignored. + * @param sortOrder ignored. The results are always returned in no particular order. + * @return a cursor matching the uri, or null if the URI was not recognized. + */ + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + DebugLogUtils.l("Uri =", uri); + PrivateLog.log("Query : " + uri); + final String clientId = getClientId(uri); + final int match = matchUri(uri); + switch (match) { + case DICTIONARY_V1_WHOLE_LIST: + case DICTIONARY_V2_WHOLE_LIST: + final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId); + DebugLogUtils.l("List of dictionaries with count", c.getCount()); + PrivateLog.log("Returned a list of " + c.getCount() + " items"); + return c; + case DICTIONARY_V2_DICT_INFO: + // In protocol version 2, we return null if the client is unknown. Otherwise + // we behave exactly like for protocol 1. + if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null; + // Fall through + case DICTIONARY_V1_DICT_INFO: + final String locale = uri.getLastPathSegment(); + final Collection<WordListInfo> dictFiles = + getDictionaryWordListsForLocale(clientId, locale); + // TODO: pass clientId to the following function + DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext()); + if (null != dictFiles && dictFiles.size() > 0) { + PrivateLog.log("Returned " + dictFiles.size() + " files"); + return new ResourcePathCursor(dictFiles); + } + PrivateLog.log("No dictionary files for this URL"); + return new ResourcePathCursor(Collections.<WordListInfo>emptyList()); + // V2_METADATA and V2_DATAFILE are not supported for query() + default: + return null; + } + } + + /** + * Helper method to get the wordlist metadata associated with a wordlist ID. + * + * @param clientId the ID of the client + * @param wordlistId the ID of the wordlist for which to get the metadata. + * @return the metadata for this wordlist ID, or null if none could be found. + */ + private ContentValues getWordlistMetadataForWordlistId(final String clientId, + final String wordlistId) { + final Context context = getContext(); + if (TextUtils.isEmpty(wordlistId)) return null; + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId( + db, wordlistId); + } + + /** + * Opens an asset file for an URI. + * + * Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or + * {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a + * dictionary. + * @see android.content.ContentProvider#openAssetFile(Uri, String) + * + * @param uri the URI the file is for. + * @param mode the mode to read the file. MUST be "r" for readonly. + * @return the descriptor, or null if the file is not found or if mode is not equals to "r". + */ + @Override + public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) { + if (null == mode || !"r".equals(mode)) return null; + + final int match = matchUri(uri); + if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) { + // Unsupported URI for openAssetFile + Log.w(TAG, "Unsupported URI for openAssetFile : " + uri); + return null; + } + final String wordlistId = uri.getLastPathSegment(); + final String clientId = getClientId(uri); + final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); + + if (null == wordList) return null; + + try { + final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DELETING == status) { + // This will return an empty file (R.raw.empty points at an empty dictionary) + // This is how we "delete" the files. It allows Kelar Keyboard to fake deleting + // a default dictionary - which is actually in its assets and can't be really + // deleted. + final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd( + R.raw.empty); + return afd; + } + final String localFilename = + wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final File f = getContext().getFileStreamPath(localFilename); + final ParcelFileDescriptor pfd = + ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY); + return new AssetFileDescriptor(pfd, 0, pfd.getStatSize()); + } catch (FileNotFoundException e) { + // No file : fall through and return null + } + return null; + } + + /** + * Reads the metadata and returns the collection of dictionaries for a given locale. + * + * Word list IDs are expected to be in the form category:manual_id. This method + * will select only one word list for each category: the one with the most specific + * locale matching the locale specified in the URI. The manual id serves only to + * distinguish a word list from another for the purpose of updating, and is arbitrary + * but may not contain a colon. + * + * @param clientId the ID of the client requesting the list + * @param locale the locale for which we want the list, as a String + * @return a collection of ids. It is guaranteed to be non-null, but may be empty. + */ + private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId, + final String locale) { + final Context context = getContext(); + final Cursor results = + MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context, + clientId); + if (null == results) { + return Collections.<WordListInfo>emptyList(); + } + try { + final HashMap<String, WordListInfo> dicts = new HashMap<>(); + final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); + final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); + final int localFileNameIndex = + results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final int rawChecksumIndex = + results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); + final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); + if (results.moveToFirst()) { + do { + final String wordListId = results.getString(idIndex); + if (TextUtils.isEmpty(wordListId)) continue; + final String[] wordListIdArray = + TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR); + final String wordListCategory; + if (2 == wordListIdArray.length) { + // This is at the category:manual_id format. + wordListCategory = wordListIdArray[0]; + // We don't need to read wordListIdArray[1] here, because it's irrelevant to + // word list selection - it's just a name we use to identify which data file + // is a newer version of which word list. We do however return the full id + // string for each selected word list, so in this sense we are 'using' it. + } else { + // This does not contain a colon, like the old format does. Old-format IDs + // always point to main dictionaries, so we force the main category upon it. + wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY; + } + final String wordListLocale = results.getString(localeIndex); + final String wordListLocalFilename = results.getString(localFileNameIndex); + final String wordListRawChecksum = results.getString(rawChecksumIndex); + final int wordListStatus = results.getInt(statusIndex); + // Test the requested locale against this wordlist locale. The requested locale + // has to either match exactly or be more specific than the dictionary - a + // dictionary for "en" would match both a request for "en" or for "en_US", but a + // dictionary for "en_GB" would not match a request for "en_US". Thus if all + // three of "en" "en_US" and "en_GB" dictionaries are installed, a request for + // "en_US" would match "en" and "en_US", and a request for "en" only would only + // match the generic "en" dictionary. For more details, see the documentation + // for LocaleUtils#getMatchLevel. + final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale); + if (!LocaleUtils.isMatch(matchLevel)) { + // The locale of this wordlist does not match the required locale. + // Skip this wordlist and go to the next. + continue; + } + if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) { + // If the file does not exist, it has been deleted and the IME should + // already have it. Do not return it. However, this only applies if the + // word list is INSTALLED, for if it is DELETING we should return it always + // so that Kelar Keyboard can perform the actual deletion. + final File f = getContext().getFileStreamPath(wordListLocalFilename); + if (!f.isFile()) { + continue; + } + } else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) { + // The locale is the id for the main dictionary. + UpdateHandler.installIfNeverRequested(context, clientId, wordListId); + continue; + } + final WordListInfo currentBestMatch = dicts.get(wordListCategory); + if (null == currentBestMatch + || currentBestMatch.mMatchLevel < matchLevel) { + dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale, + wordListRawChecksum, matchLevel)); + } + } while (results.moveToNext()); + } + return Collections.unmodifiableCollection(dicts.values()); + } finally { + results.close(); + } + } + + /** + * Deletes the file pointed by Uri, as returned by openAssetFile. + * + * @param uri the URI the file is for. + * @param selection ignored + * @param selectionArgs ignored + * @return the number of files deleted (0 or 1 in the current implementation) + * @see android.content.ContentProvider#delete(Uri, String, String[]) + */ + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) + throws UnsupportedOperationException { + final int match = matchUri(uri); + if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) { + return deleteDataFile(uri); + } + if (DICTIONARY_V2_METADATA == match) { + if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) { + return 1; + } + return 0; + } + // Unsupported URI for delete + return 0; + } + + private int deleteDataFile(final Uri uri) { + final String wordlistId = uri.getLastPathSegment(); + final String clientId = getClientId(uri); + final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId); + if (null == wordList) { + return 0; + } + final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN); + if (MetadataDbHelper.STATUS_DELETING == status) { + UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status); + return 1; + } + if (MetadataDbHelper.STATUS_INSTALLED == status) { + final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT); + if (QUERY_PARAMETER_FAILURE.equals(result)) { + if (DEBUG) { + Log.d(TAG, + "Dictionary is broken, attempting to retry download & installation."); + } + UpdateHandler.markAsBrokenOrRetrying(getContext(), clientId, wordlistId, version); + } + final String localFilename = + wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final File f = getContext().getFileStreamPath(localFilename); + // f.delete() returns true if the file was successfully deleted, false otherwise + return f.delete() ? 1 : 0; + } + Log.e(TAG, "Attempt to delete a file whose status is " + status); + return 0; + } + + /** + * Insert data into the provider. May be either a metadata source URL or some dictionary info. + * + * @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs. + * @param values the values to insert for this content uri + * @return the URI for the newly inserted item. May be null if arguments don't allow for insert + */ + @Override + public Uri insert(final Uri uri, final ContentValues values) + throws UnsupportedOperationException { + if (null == uri || null == values) return null; // Should never happen but let's be safe + PrivateLog.log("Insert, uri = " + uri.toString()); + final String clientId = getClientId(uri); + switch (matchUri(uri)) { + case DICTIONARY_V2_METADATA: + // The values should contain a valid client ID and a valid URI for the metadata. + // The client ID may not be null, nor may it be empty because the empty client ID + // is reserved for internal use. + // The metadata URI may not be null, but it may be empty if the client does not + // want the dictionary pack to update the metadata automatically. + MetadataDbHelper.updateClientInfo(getContext(), clientId, values); + break; + case DICTIONARY_V2_DICT_INFO: + try { + final WordListMetadata newDictionaryMetadata = + WordListMetadata.createFromContentValues( + MetadataDbHelper.completeWithDefaultValues(values)); + new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata) + .execute(getContext()); + } catch (final BadFormatException e) { + Log.w(TAG, "Not enough information to insert this dictionary " + values, e); + } + // We just received new information about the list of dictionary for this client. + // For all intents and purposes, this is new metadata, so we should publish it + // so that any listeners (like the Settings interface for example) can update + // themselves. + UpdateHandler.publishUpdateMetadataCompleted(getContext(), true); + break; + case DICTIONARY_V1_WHOLE_LIST: + case DICTIONARY_V1_DICT_INFO: + PrivateLog.log("Attempt to insert : " + uri); + throw new UnsupportedOperationException( + "Insertion in the dictionary is not supported in this version"); + } + return uri; + } + + /** + * Updating data is not supported, and will throw an exception. + * @see android.content.ContentProvider#update(Uri, ContentValues, String, String[]) + * @see android.content.ContentProvider#insert(Uri, ContentValues) + */ + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) throws UnsupportedOperationException { + PrivateLog.log("Attempt to update : " + uri); + throw new UnsupportedOperationException("Updating dictionary words is not supported"); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java new file mode 100644 index 000000000..851a1d925 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java @@ -0,0 +1,280 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; +import android.widget.Toast; + +import org.kelar.inputmethod.latin.BinaryDictionaryFileDumper; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import java.util.Locale; +import java.util.Random; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; + +/** + * Service that handles background tasks for the dictionary provider. + * + * This service provides the context for the long-running operations done by the + * dictionary provider. Those include: + * - Checking for the last update date and scheduling the next update. This runs every + * day around midnight, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast. + * Every four days, it schedules an update of the metadata with the alarm manager. + * - Issuing the order to update the metadata. This runs every four days, between 0 and + * 6, upon reception of the UPDATE_NOW_INTENT_ACTION broadcast sent by the alarm manager + * as a result of the above action. + * - Handling a download that just ended. These come in two flavors: + * - Metadata is finished downloading. We should check whether there are new dictionaries + * available, and download those that we need that have new versions. + * - A dictionary file finished downloading. We should put the file ready for a client IME + * to access, and mark the current state as such. + */ +public final class DictionaryService extends Service { + private static final String TAG = DictionaryService.class.getSimpleName(); + + /** + * The package name, to use in the intent actions. + */ + private static final String PACKAGE_NAME = "org.kelar.inputmethod.latin"; + + /** + * The action of the date changing, used to schedule a periodic freshness check + */ + private static final String DATE_CHANGED_INTENT_ACTION = + Intent.ACTION_DATE_CHANGED; + + /** + * The action of displaying a toast to warn the user an automatic download is starting. + */ + /* package */ static final String SHOW_DOWNLOAD_TOAST_INTENT_ACTION = + PACKAGE_NAME + ".SHOW_DOWNLOAD_TOAST_INTENT_ACTION"; + + /** + * A locale argument, as a String. + */ + /* package */ static final String LOCALE_INTENT_ARGUMENT = "locale"; + + /** + * How often, in milliseconds, we want to update the metadata. This is a + * floor value; actually, it may happen several hours later, or even more. + */ + private static final long UPDATE_FREQUENCY_MILLIS = TimeUnit.DAYS.toMillis(4); + + /** + * We are waked around midnight, local time. We want to wake between midnight and 6 am, + * roughly. So use a random time between 0 and this delay. + */ + private static final int MAX_ALARM_DELAY_MILLIS = (int)TimeUnit.HOURS.toMillis(6); + + /** + * How long we consider a "very long time". If no update took place in this time, + * the content provider will trigger an update in the background. + */ + private static final long VERY_LONG_TIME_MILLIS = TimeUnit.DAYS.toMillis(14); + + /** + * After starting a download, how long we wait before considering it may be stuck. After this + * period is elapsed, if the keyboard tries to download again, then we cancel and re-register + * the request; if it's within this time, we just leave it be. + * It's important to note that we do not re-submit the request merely because the time is up. + * This is only to decide whether to cancel the old one and re-requesting when the keyboard + * fires a new request for the same data. + */ + public static final long NO_CANCEL_DOWNLOAD_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(30); + + /** + * An executor that serializes tasks given to it. + */ + private ThreadPoolExecutor mExecutor; + private static final int WORKER_THREAD_TIMEOUT_SECONDS = 15; + + @Override + public void onCreate() { + // By default, a thread pool executor does not timeout its core threads, so it will + // never kill them when there isn't any work to do any more. That would mean the service + // can never die! By creating it this way and calling allowCoreThreadTimeOut, we allow + // the single thread to time out after WORKER_THREAD_TIMEOUT_SECONDS = 15 seconds, allowing + // the process to be reclaimed by the system any time after that if it's not doing + // anything else. + // Executors#newSingleThreadExecutor creates a ThreadPoolExecutor but it returns the + // superclass ExecutorService which does not have the #allowCoreThreadTimeOut method, + // so we can't use that. + mExecutor = new ThreadPoolExecutor(1 /* corePoolSize */, 1 /* maximumPoolSize */, + WORKER_THREAD_TIMEOUT_SECONDS /* keepAliveTime */, + TimeUnit.SECONDS /* unit for keepAliveTime */, + new LinkedBlockingQueue<Runnable>() /* workQueue */); + mExecutor.allowCoreThreadTimeOut(true); + } + + @Override + public void onDestroy() { + } + + @Override + public IBinder onBind(Intent intent) { + // This service cannot be bound + return null; + } + + /** + * Executes an explicit command. + * + * This is the entry point for arbitrary commands that are executed upon reception of certain + * events that should be executed on the context of this service. The supported commands are: + * - Check last update time and possibly schedule an update of the data for later. + * This is triggered every day, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast. + * - Update data NOW. + * This is normally received upon trigger of the scheduled update. + * - Handle a finished download. + * This executes the actions that must be taken after a file (metadata or dictionary data + * has been downloaded (or failed to download). + * The commands that can be spun an another thread will be executed serially, in order, on + * a worker thread that is created on demand and terminates after a short while if there isn't + * any work left to do. + */ + @Override + public synchronized int onStartCommand(final Intent intent, final int flags, + final int startId) { + final DictionaryService self = this; + if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) { + final String localeString = intent.getStringExtra(LOCALE_INTENT_ARGUMENT); + if (localeString == null) { + Log.e(TAG, "Received " + intent.getAction() + " without locale; skipped"); + } else { + // This is a UI action, it can't be run in another thread + showStartDownloadingToast( + this, LocaleUtils.constructLocaleFromString(localeString)); + } + } else { + // If it's a command that does not require UI, arrange for the work to be done on a + // separate thread, so that we can return right away. The executor will spawn a thread + // if necessary, or reuse a thread that has become idle as appropriate. + // DATE_CHANGED or UPDATE_NOW are examples of commands that can be done on another + // thread. + mExecutor.submit(new Runnable() { + @Override + public void run() { + dispatchBroadcast(self, intent); + // Since calls to onStartCommand are serialized, the submissions to the executor + // are serialized. That means we are guaranteed to call the stopSelfResult() + // in the same order that we got them, so we don't need to take care of the + // order. + stopSelfResult(startId); + } + }); + } + return Service.START_REDELIVER_INTENT; + } + + static void dispatchBroadcast(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (DATE_CHANGED_INTENT_ACTION.equals(action)) { + // This happens when the date of the device changes. This normally happens + // at midnight local time, but it may happen if the user changes the date + // by hand or something similar happens. + checkTimeAndMaybeSetupUpdateAlarm(context); + } else if (DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION.equals(action)) { + // Intent to trigger an update now. + UpdateHandler.tryUpdate(context); + } else if (DictionaryPackConstants.INIT_AND_UPDATE_NOW_INTENT_ACTION.equals(action)) { + // Initialize the client Db. + final String mClientId = context.getString(R.string.dictionary_pack_client_id); + BinaryDictionaryFileDumper.initializeClientRecordHelper(context, mClientId); + + // Updates the metadata and the download the dictionaries. + UpdateHandler.tryUpdate(context); + } else { + UpdateHandler.downloadFinished(context, intent); + } + } + + /** + * Setups an alarm to check for updates if an update is due. + */ + private static void checkTimeAndMaybeSetupUpdateAlarm(final Context context) { + // Of all clients, if the one that hasn't been updated for the longest + // is still more recent than UPDATE_FREQUENCY_MILLIS, do nothing. + if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY_MILLIS)) return; + + PrivateLog.log("Date changed - registering alarm"); + AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); + + // Best effort to wake between midnight and MAX_ALARM_DELAY_MILLIS in the morning. + // It doesn't matter too much if this is very inexact. + final long now = System.currentTimeMillis(); + final long alarmTime = now + new Random().nextInt(MAX_ALARM_DELAY_MILLIS); + final Intent updateIntent = new Intent(DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION); + // Set the package name to ensure the PendingIntent is only delivered to trusted components + updateIntent.setPackage(context.getPackageName()); + int pendingIntentFlags = PendingIntent.FLAG_CANCEL_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 23) { + pendingIntentFlags |= PendingIntent.FLAG_IMMUTABLE; + } + final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, + updateIntent, pendingIntentFlags); + + // We set the alarm in the type that doesn't forcefully wake the device + // from sleep, but fires the next time the device actually wakes for any + // other reason. + if (null != alarmManager) alarmManager.set(AlarmManager.RTC, alarmTime, pendingIntent); + } + + /** + * Utility method to decide whether the last update is older than a certain time. + * + * @return true if at least `time' milliseconds have elapsed since last update, false otherwise. + */ + private static boolean isLastUpdateAtLeastThisOld(final Context context, final long time) { + final long now = System.currentTimeMillis(); + final long lastUpdate = MetadataDbHelper.getOldestUpdateTime(context); + PrivateLog.log("Last update was " + lastUpdate); + return lastUpdate + time < now; + } + + /** + * Refreshes data if it hasn't been refreshed in a very long time. + * + * This will check the last update time, and if it's been more than VERY_LONG_TIME_MILLIS, + * update metadata now - and possibly take subsequent update actions. + */ + public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) { + if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME_MILLIS)) return; + UpdateHandler.tryUpdate(context); + } + + /** + * Shows a toast informing the user that an automatic dictionary download is starting. + */ + private static void showStartDownloadingToast(final Context context, + @Nonnull final Locale locale) { + final String toastText = String.format( + context.getString(R.string.toast_downloading_suggestions), + locale.getDisplayName()); + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java new file mode 100644 index 000000000..f86eda177 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java @@ -0,0 +1,54 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import org.kelar.inputmethod.latin.utils.FragmentUtils; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceActivity; + +/** + * Preference screen. + */ +public final class DictionarySettingsActivity extends PreferenceActivity { + private static final String DEFAULT_FRAGMENT = DictionarySettingsFragment.class.getName(); + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public Intent getIntent() { + final Intent modIntent = new Intent(super.getIntent()); + modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT); + modIntent.putExtra(EXTRA_NO_HEADERS, true); + // Important note : the original intent should contain a String extra with the key + // DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the + // fragment can know who the client is. + return modIntent; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + @Override + public boolean isValidFragment(String fragmentName) { + return FragmentUtils.isValidFragment(fragmentName); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java new file mode 100644 index 000000000..a4783a78f --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java @@ -0,0 +1,438 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; + +import org.kelar.inputmethod.latin.R; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Locale; +import java.util.TreeMap; + +/** + * Preference screen. + */ +public final class DictionarySettingsFragment extends PreferenceFragment + implements UpdateHandler.UpdateEventListener { + private static final String TAG = DictionarySettingsFragment.class.getSimpleName(); + + static final private String DICT_LIST_ID = "list"; + static final public String DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId"; + + static final private int MENU_UPDATE_NOW = Menu.FIRST; + + private View mLoadingView; + private String mClientId; + private ConnectivityManager mConnectivityManager; + private MenuItem mUpdateNowMenu; + private boolean mChangedSettings; + private DictionaryListInterfaceState mDictionaryListInterfaceState = + new DictionaryListInterfaceState(); + // never null + private TreeMap<String, WordListPreference> mCurrentPreferenceMap = new TreeMap<>(); + + private final BroadcastReceiver mConnectivityChangedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + refreshNetworkState(); + } + }; + + /** + * Empty constructor for fragment generation. + */ + public DictionarySettingsFragment() { + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View v = inflater.inflate(R.layout.loading_page, container, true); + mLoadingView = v.findViewById(R.id.loading_container); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + final Activity activity = getActivity(); + mClientId = activity.getIntent().getStringExtra(DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT); + mConnectivityManager = + (ConnectivityManager)activity.getSystemService(Context.CONNECTIVITY_SERVICE); + addPreferencesFromResource(R.xml.dictionary_settings); + refreshInterface(); + setHasOptionsMenu(true); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + new AsyncTask<Void, Void, String>() { + @Override + protected String doInBackground(Void... params) { + return MetadataDbHelper.getMetadataUriAsString(getActivity(), mClientId); + } + + @Override + protected void onPostExecute(String metadataUri) { + // We only add the "Refresh" button if we have a non-empty URL to refresh from. If + // the URL is empty, of course we can't refresh so it makes no sense to display + // this. + if (!TextUtils.isEmpty(metadataUri)) { + if (mUpdateNowMenu == null) { + mUpdateNowMenu = menu.add(Menu.NONE, MENU_UPDATE_NOW, 0, + R.string.check_for_updates_now); + mUpdateNowMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + } + refreshNetworkState(); + } + } + }.execute(); + } + + @Override + public void onResume() { + super.onResume(); + mChangedSettings = false; + UpdateHandler.registerUpdateEventListener(this); + final Activity activity = getActivity(); + final IntentFilter filter = new IntentFilter(); + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + getActivity().registerReceiver(mConnectivityChangedReceiver, filter); + refreshNetworkState(); + + new Thread("onResume") { + @Override + public void run() { + if (!MetadataDbHelper.isClientKnown(activity, mClientId)) { + Log.i(TAG, "Unknown dictionary pack client: " + mClientId + + ". Requesting info."); + final Intent unknownClientBroadcast = + new Intent(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT); + unknownClientBroadcast.putExtra( + DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA, mClientId); + activity.sendBroadcast(unknownClientBroadcast); + } + } + }.start(); + } + + @Override + public void onPause() { + super.onPause(); + final Activity activity = getActivity(); + UpdateHandler.unregisterUpdateEventListener(this); + activity.unregisterReceiver(mConnectivityChangedReceiver); + if (mChangedSettings) { + final Intent newDictBroadcast = + new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + activity.sendBroadcast(newDictBroadcast); + mChangedSettings = false; + } + } + + @Override + public void downloadedMetadata(final boolean succeeded) { + stopLoadingAnimation(); + if (!succeeded) return; // If the download failed nothing changed, so no need to refresh + new Thread("refreshInterface") { + @Override + public void run() { + refreshInterface(); + } + }.start(); + } + + @Override + public void wordListDownloadFinished(final String wordListId, final boolean succeeded) { + final WordListPreference pref = findWordListPreference(wordListId); + if (null == pref) return; + // TODO: Report to the user if !succeeded + final Activity activity = getActivity(); + if (null == activity) return; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + // We have to re-read the db in case the description has changed, and to + // find out what state it ended up if the download wasn't successful + // TODO: don't redo everything, only re-read and set this word list status + refreshInterface(); + } + }); + } + + private WordListPreference findWordListPreference(final String id) { + final PreferenceGroup prefScreen = getPreferenceScreen(); + if (null == prefScreen) { + Log.e(TAG, "Could not find the preference group"); + return null; + } + for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; --i) { + final Preference pref = prefScreen.getPreference(i); + if (pref instanceof WordListPreference) { + final WordListPreference wlPref = (WordListPreference)pref; + if (id.equals(wlPref.mWordlistId)) { + return wlPref; + } + } + } + Log.e(TAG, "Could not find the preference for a word list id " + id); + return null; + } + + @Override + public void updateCycleCompleted() {} + + void refreshNetworkState() { + NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); + boolean isConnected = null == info ? false : info.isConnected(); + if (null != mUpdateNowMenu) mUpdateNowMenu.setEnabled(isConnected); + } + + void refreshInterface() { + final Activity activity = getActivity(); + if (null == activity) return; + final PreferenceGroup prefScreen = getPreferenceScreen(); + final Collection<? extends Preference> prefList = + createInstalledDictSettingsCollection(mClientId); + + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + // TODO: display this somewhere + // if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary); + refreshNetworkState(); + + removeAnyDictSettings(prefScreen); + int i = 0; + for (Preference preference : prefList) { + preference.setOrder(i++); + prefScreen.addPreference(preference); + } + } + }); + } + + private static Preference createErrorMessage(final Activity activity, final int messageResource) { + final Preference message = new Preference(activity); + message.setTitle(messageResource); + message.setEnabled(false); + return message; + } + + static void removeAnyDictSettings(final PreferenceGroup prefGroup) { + for (int i = prefGroup.getPreferenceCount() - 1; i >= 0; --i) { + prefGroup.removePreference(prefGroup.getPreference(i)); + } + } + + /** + * Creates a WordListPreference list to be added to the screen. + * + * This method only creates the preferences but does not add them. + * Thus, it can be called on another thread. + * + * @param clientId the id of the client for which we want to display the dictionary list + * @return A collection of preferences ready to add to the interface. + */ + private Collection<? extends Preference> createInstalledDictSettingsCollection( + final String clientId) { + // This will directly contact the DictionaryProvider and request the list exactly like + // any regular client would do. + // Considering the respective value of the respective constants used here for each path, + // segment, the url generated by this is of the form (assuming "clientId" as a clientId) + // content://org.kelar.inputmethod.latin.dictionarypack/clientId/list?procotol=2 + final Uri contentUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT) + .authority(getString(R.string.authority)) + .appendPath(clientId) + .appendPath(DICT_LIST_ID) + // Need to use version 2 to get this client's list + .appendQueryParameter(DictionaryProvider.QUERY_PARAMETER_PROTOCOL_VERSION, "2") + .build(); + final Activity activity = getActivity(); + final Cursor cursor = (null == activity) ? null + : activity.getContentResolver().query(contentUri, null, null, null, null); + + if (null == cursor) { + final ArrayList<Preference> result = new ArrayList<>(); + result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service)); + return result; + } + try { + if (!cursor.moveToFirst()) { + final ArrayList<Preference> result = new ArrayList<>(); + result.add(createErrorMessage(activity, R.string.no_dictionaries_available)); + return result; + } + final String systemLocaleString = Locale.getDefault().toString(); + final TreeMap<String, WordListPreference> prefMap = new TreeMap<>(); + final int idIndex = cursor.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); + final int versionIndex = cursor.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); + final int localeIndex = cursor.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); + final int descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); + final int statusIndex = cursor.getColumnIndex(MetadataDbHelper.STATUS_COLUMN); + final int filesizeIndex = cursor.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); + do { + final String wordlistId = cursor.getString(idIndex); + final int version = cursor.getInt(versionIndex); + final String localeString = cursor.getString(localeIndex); + final Locale locale = new Locale(localeString); + final String description = cursor.getString(descriptionIndex); + final int status = cursor.getInt(statusIndex); + final int matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString); + final String matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel); + final int filesize = cursor.getInt(filesizeIndex); + // The key is sorted in lexicographic order, according to the match level, then + // the description. + final String key = matchLevelString + "." + description + "." + wordlistId; + final WordListPreference existingPref = prefMap.get(key); + if (null == existingPref || existingPref.hasPriorityOver(status)) { + final WordListPreference oldPreference = mCurrentPreferenceMap.get(key); + final WordListPreference pref; + if (null != oldPreference + && oldPreference.mVersion == version + && oldPreference.hasStatus(status) + && oldPreference.mLocale.equals(locale)) { + // If the old preference has all the new attributes, reuse it. Ideally, + // we should reuse the old pref even if its status is different and call + // setStatus here, but setStatus calls Preference#setSummary() which + // needs to be done on the UI thread and we're not on the UI thread + // here. We could do all this work on the UI thread, but in this case + // it's probably lighter to stay on a background thread and throw this + // old preference out. + pref = oldPreference; + } else { + // Otherwise, discard it and create a new one instead. + // TODO: when the status is different from the old one, we need to + // animate the old one out before animating the new one in. + pref = new WordListPreference(activity, mDictionaryListInterfaceState, + mClientId, wordlistId, version, locale, description, status, + filesize); + } + prefMap.put(key, pref); + } + } while (cursor.moveToNext()); + mCurrentPreferenceMap = prefMap; + return prefMap.values(); + } finally { + cursor.close(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case MENU_UPDATE_NOW: + if (View.GONE == mLoadingView.getVisibility()) { + startRefresh(); + } else { + cancelRefresh(); + } + return true; + } + return false; + } + + private void startRefresh() { + startLoadingAnimation(); + mChangedSettings = true; + UpdateHandler.registerUpdateEventListener(this); + final Activity activity = getActivity(); + new Thread("updateByHand") { + @Override + public void run() { + // We call tryUpdate(), which returns whether we could successfully start an update. + // If we couldn't, we'll never receive the end callback, so we stop the loading + // animation and return to the previous screen. + if (!UpdateHandler.tryUpdate(activity)) { + stopLoadingAnimation(); + } + } + }.start(); + } + + private void cancelRefresh() { + UpdateHandler.unregisterUpdateEventListener(this); + final Context context = getActivity(); + new Thread("cancelByHand") { + @Override + public void run() { + UpdateHandler.cancelUpdate(context, mClientId); + stopLoadingAnimation(); + } + }.start(); + } + + private void startLoadingAnimation() { + mLoadingView.setVisibility(View.VISIBLE); + getView().setVisibility(View.GONE); + // We come here when the menu element is pressed so presumably it can't be null. But + // better safe than sorry. + if (null != mUpdateNowMenu) mUpdateNowMenu.setTitle(R.string.cancel); + } + + void stopLoadingAnimation() { + final View preferenceView = getView(); + final Activity activity = getActivity(); + if (null == activity) return; + final View loadingView = mLoadingView; + final MenuItem updateNowMenu = mUpdateNowMenu; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + loadingView.setVisibility(View.GONE); + preferenceView.setVisibility(View.VISIBLE); + loadingView.startAnimation(AnimationUtils.loadAnimation( + activity, android.R.anim.fade_out)); + preferenceView.startAnimation(AnimationUtils.loadAnimation( + activity, android.R.anim.fade_in)); + // The menu is created by the framework asynchronously after the activity, + // which means it's possible to have the activity running but the menu not + // created yet - hence the necessity for a null check here. + if (null != updateNowMenu) { + updateNowMenu.setTitle(R.string.check_for_updates_now); + } + } + }); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java new file mode 100644 index 000000000..cb58abfd5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.dictionarypack; + +/** + * A simple container of download ID and download start date. + */ +public class DownloadIdAndStartDate { + public final long mId; + public final long mStartDate; + public DownloadIdAndStartDate(final long id, final long startDate) { + mId = id; + mStartDate = startDate; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java new file mode 100644 index 000000000..5881cecf1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.dictionarypack; + +import android.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.app.DownloadManager.Request; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.util.Arrays; + +import javax.annotation.Nullable; + +/** + * A class to help with calling DownloadManager methods. + * + * Mostly, the problem here is that most methods from DownloadManager may throw SQL exceptions if + * they can't open the database on disk. We want to avoid crashing in these cases but can't do + * much more, so this class insulates the callers from these. SQLiteException also inherit from + * RuntimeException so they are unchecked :( + * While we're at it, we also insulate callers from the cases where DownloadManager is disabled, + * and getSystemService returns null. + */ +public class DownloadManagerWrapper { + private final static String TAG = DownloadManagerWrapper.class.getSimpleName(); + private final DownloadManager mDownloadManager; + + public DownloadManagerWrapper(final Context context) { + this((DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE)); + } + + private DownloadManagerWrapper(final DownloadManager downloadManager) { + mDownloadManager = downloadManager; + } + + public void remove(final long... ids) { + try { + if (null != mDownloadManager) { + mDownloadManager.remove(ids); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + // We couldn't remove the file from DownloadManager. Apparently, the database can't + // be opened. It may be a problem with file system corruption. In any case, there is + // not much we can do apart from avoiding crashing. + Log.e(TAG, "Can't remove files with ID " + Arrays.toString(ids) + + " from download manager", e); + } + } + + public ParcelFileDescriptor openDownloadedFile(final long fileId) throws FileNotFoundException { + try { + if (null != mDownloadManager) { + return mDownloadManager.openDownloadedFile(fileId); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't open downloaded file with ID " + fileId, e); + } + // We come here if mDownloadManager is null or if an exception was thrown. + throw new FileNotFoundException(); + } + + @Nullable + public Cursor query(final Query query) { + try { + if (null != mDownloadManager) { + return mDownloadManager.query(query); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't query the download manager", e); + } + // We come here if mDownloadManager is null or if an exception was thrown. + return null; + } + + public long enqueue(final Request request) { + try { + if (null != mDownloadManager) { + return mDownloadManager.enqueue(request); + } + } catch (IllegalArgumentException e) { + // This is expected to happen on boot when the device is encrypted. + } catch (SQLiteException e) { + Log.e(TAG, "Can't enqueue a request with the download manager", e); + } + return 0; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java new file mode 100644 index 000000000..48564535a --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java @@ -0,0 +1,86 @@ +/* + * 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 org.kelar.inputmethod.dictionarypack; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.Html; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import javax.annotation.Nullable; + +/** + * This implements the dialog for asking the user whether it's okay to download dictionaries over + * a metered connection or not (e.g. their mobile data plan). + */ +public final class DownloadOverMeteredDialog extends Activity { + final public static String CLIENT_ID_KEY = "client_id"; + final public static String WORDLIST_TO_DOWNLOAD_KEY = "wordlist_to_download"; + final public static String SIZE_KEY = "size"; + final public static String LOCALE_KEY = "locale"; + private String mClientId; + private String mWordListToDownload; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + mClientId = intent.getStringExtra(CLIENT_ID_KEY); + mWordListToDownload = intent.getStringExtra(WORDLIST_TO_DOWNLOAD_KEY); + final String localeString = intent.getStringExtra(LOCALE_KEY); + final long size = intent.getIntExtra(SIZE_KEY, 0); + setContentView(R.layout.download_over_metered); + setTexts(localeString, size); + } + + private void setTexts(@Nullable final String localeString, final long size) { + final String promptFormat = getString(R.string.should_download_over_metered_prompt); + final String allowButtonFormat = getString(R.string.download_over_metered); + final String language = (null == localeString) ? "" + : LocaleUtils.constructLocaleFromString(localeString).getDisplayLanguage(); + final TextView prompt = (TextView)findViewById(R.id.download_over_metered_prompt); + prompt.setText(Html.fromHtml(String.format(promptFormat, language))); + final Button allowButton = (Button)findViewById(R.id.allow_button); + allowButton.setText(String.format(allowButtonFormat, ((float)size)/(1024*1024))); + } + + // This method is externally referenced from layout/download_over_metered.xml using onClick + // attribute of Button. + @ExternallyReferenced + @SuppressWarnings("unused") + public void onClickDeny(final View v) { + UpdateHandler.setDownloadOverMeteredSetting(this, false); + finish(); + } + + // This method is externally referenced from layout/download_over_metered.xml using onClick + // attribute of Button. + @ExternallyReferenced + @SuppressWarnings("unused") + public void onClickAllow(final View v) { + UpdateHandler.setDownloadOverMeteredSetting(this, true); + UpdateHandler.installIfNeverRequested(this, mClientId, mWordListToDownload); + finish(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java b/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java new file mode 100644 index 000000000..1dddc6042 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java @@ -0,0 +1,37 @@ +/* + * 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentValues; + +/** + * Struct class to encapsulate a client ID with content values about a download. + */ +public class DownloadRecord { + public final String mClientId; + // Only word lists have attributes, and the ContentValues should contain the same + // keys as they do for all MetadataDbHelper functions. Since only word lists have + // attributes, a null pointer here means this record represents metadata. + public final ContentValues mAttributes; + public DownloadRecord(final String clientId, final ContentValues attributes) { + mClientId = clientId; + mAttributes = attributes; + } + public boolean isMetadata() { + return null == mAttributes; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java new file mode 100644 index 000000000..c62597eff --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public final class EventHandler extends BroadcastReceiver { + /** + * Receives a intent broadcast. + * + * We receive every day a broadcast indicating that date changed. + * Then we wait a random amount of time before actually registering + * the download, to avoid concentrating too many accesses around + * midnight in more populated timezones. + * We receive all broadcasts here, so this can be either the DATE_CHANGED broadcast, the + * UPDATE_NOW private broadcast that we receive when the time-randomizing alarm triggers + * for regular update or from applications that want to test the dictionary pack, or a + * broadcast from DownloadManager telling that a download has finished. + * See inside of AndroidManifest.xml to see which events are caught. + * Also @see {@link BroadcastReceiver#onReceive(Context, Intent)} + * + * @param context the context of the application. + * @param intent the intent that was broadcast. + */ + @Override + public void onReceive(final Context context, final Intent intent) { + intent.setClass(context, DictionaryService.class); + context.startService(intent); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java b/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java new file mode 100644 index 000000000..1f1f9dc58 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.util.Log; + +/** + * A very simple problem reporter. + */ +final class LogProblemReporter implements ProblemReporter { + private final String TAG; + + public LogProblemReporter(final String tag) { + TAG = tag; + } + + @Override + public void report(final Exception e) { + Log.e(TAG, "Reporting problem", e); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java b/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java new file mode 100644 index 000000000..80d81c090 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java @@ -0,0 +1,46 @@ +/** + * 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 org.kelar.inputmethod.dictionarypack; + +import java.io.InputStream; +import java.io.IOException; +import java.security.MessageDigest; + +public final class MD5Calculator { + private MD5Calculator() {} // This helper class is not instantiable + + public static String checksum(final InputStream in) throws IOException { + // This code from the Android documentation for MessageDigest. Nearly verbatim. + MessageDigest digester; + try { + digester = MessageDigest.getInstance("MD5"); + } catch (java.security.NoSuchAlgorithmException e) { + return null; // Platform does not support MD5 : can't check, so return null + } + final byte[] bytes = new byte[8192]; + int byteCount; + while ((byteCount = in.read(bytes)) > 0) { + digester.update(bytes, 0, byteCount); + } + final byte[] digest = digester.digest(); + final StringBuilder s = new StringBuilder(); + for (int i = 0; i < digest.length; ++i) { + s.append(String.format("%1$02x", digest[i])); + } + return s.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java new file mode 100644 index 000000000..b8e093997 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java @@ -0,0 +1,1155 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.TreeMap; + +import javax.annotation.Nullable; + +/** + * Various helper functions for the state database + */ +public class MetadataDbHelper extends SQLiteOpenHelper { + private static final String TAG = MetadataDbHelper.class.getSimpleName(); + + // This was the initial release version of the database. It should never be + // changed going forward. + private static final int METADATA_DATABASE_INITIAL_VERSION = 3; + // This is the first released version of the database that implements CLIENTID. It is + // used to identify the versions for upgrades. This should never change going forward. + private static final int METADATA_DATABASE_VERSION_WITH_CLIENTID = 6; + // The current database version. + // This MUST be increased every time the dictionary pack metadata URL changes. + private static final int CURRENT_METADATA_DATABASE_VERSION = 16; + + private final static long NOT_A_DOWNLOAD_ID = -1; + + // The number of retries allowed when attempting to download a broken dictionary. + public static final int DICTIONARY_RETRY_THRESHOLD = 2; + + public static final String METADATA_TABLE_NAME = "pendingUpdates"; + static final String CLIENT_TABLE_NAME = "clients"; + public static final String PENDINGID_COLUMN = "pendingid"; // Download Manager ID + public static final String TYPE_COLUMN = "type"; + public static final String STATUS_COLUMN = "status"; + public static final String LOCALE_COLUMN = "locale"; + public static final String WORDLISTID_COLUMN = "id"; + public static final String DESCRIPTION_COLUMN = "description"; + public static final String LOCAL_FILENAME_COLUMN = "filename"; + public static final String REMOTE_FILENAME_COLUMN = "url"; + public static final String DATE_COLUMN = "date"; + public static final String CHECKSUM_COLUMN = "checksum"; + public static final String FILESIZE_COLUMN = "filesize"; + public static final String VERSION_COLUMN = "version"; + public static final String FORMATVERSION_COLUMN = "formatversion"; + public static final String FLAGS_COLUMN = "flags"; + public static final String RAW_CHECKSUM_COLUMN = "rawChecksum"; + public static final String RETRY_COUNT_COLUMN = "remainingRetries"; + public static final int COLUMN_COUNT = 15; + + private static final String CLIENT_CLIENT_ID_COLUMN = "clientid"; + private static final String CLIENT_METADATA_URI_COLUMN = "uri"; + private static final String CLIENT_METADATA_ADDITIONAL_ID_COLUMN = "additionalid"; + private static final String CLIENT_LAST_UPDATE_DATE_COLUMN = "lastupdate"; + private static final String CLIENT_PENDINGID_COLUMN = "pendingid"; // Download Manager ID + + public static final String METADATA_DATABASE_NAME_STEM = "pendingUpdates"; + public static final String METADATA_UPDATE_DESCRIPTION = "metadata"; + + public static final String DICTIONARIES_ASSETS_PATH = "dictionaries"; + + // Statuses, for storing in the STATUS_COLUMN + // IMPORTANT: The following are used as index arrays in ../WordListPreference + // Do not change their values without updating the matched code. + // Unknown status: this should never happen. + public static final int STATUS_UNKNOWN = 0; + // Available: this word list is available, but it is not downloaded (not downloading), because + // it is set not to be used. + public static final int STATUS_AVAILABLE = 1; + // Downloading: this word list is being downloaded. + public static final int STATUS_DOWNLOADING = 2; + // Installed: this word list is installed and usable. + public static final int STATUS_INSTALLED = 3; + // Disabled: this word list is installed, but has been disabled by the user. + public static final int STATUS_DISABLED = 4; + // Deleting: the user marked this word list to be deleted, but it has not been yet because + // Latin IME is not up yet. + public static final int STATUS_DELETING = 5; + // Retry: dictionary got corrupted, so an attempt must be done to download & install it again. + public static final int STATUS_RETRYING = 6; + + // Types, for storing in the TYPE_COLUMN + // This is metadata about what is available. + public static final int TYPE_METADATA = 1; + // This is a bulk file. It should replace older files. + public static final int TYPE_BULK = 2; + // This is an incremental update, expected to be small, and meaningless on its own. + public static final int TYPE_UPDATE = 3; + + private static final String METADATA_TABLE_CREATE = + "CREATE TABLE " + METADATA_TABLE_NAME + " (" + + PENDINGID_COLUMN + " INTEGER, " + + TYPE_COLUMN + " INTEGER, " + + STATUS_COLUMN + " INTEGER, " + + WORDLISTID_COLUMN + " TEXT, " + + LOCALE_COLUMN + " TEXT, " + + DESCRIPTION_COLUMN + " TEXT, " + + LOCAL_FILENAME_COLUMN + " TEXT, " + + REMOTE_FILENAME_COLUMN + " TEXT, " + + DATE_COLUMN + " INTEGER, " + + CHECKSUM_COLUMN + " TEXT, " + + FILESIZE_COLUMN + " INTEGER, " + + VERSION_COLUMN + " INTEGER," + + FORMATVERSION_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + RAW_CHECKSUM_COLUMN + " TEXT," + + RETRY_COUNT_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + WORDLISTID_COLUMN + "," + VERSION_COLUMN + "));"; + private static final String METADATA_CREATE_CLIENT_TABLE = + "CREATE TABLE IF NOT EXISTS " + CLIENT_TABLE_NAME + " (" + + CLIENT_CLIENT_ID_COLUMN + " TEXT, " + + CLIENT_METADATA_URI_COLUMN + " TEXT, " + + CLIENT_METADATA_ADDITIONAL_ID_COLUMN + " TEXT, " + + CLIENT_LAST_UPDATE_DATE_COLUMN + " INTEGER NOT NULL DEFAULT 0, " + + CLIENT_PENDINGID_COLUMN + " INTEGER, " + + FLAGS_COLUMN + " INTEGER, " + + "PRIMARY KEY (" + CLIENT_CLIENT_ID_COLUMN + "));"; + + // List of all metadata table columns. + static final String[] METADATA_TABLE_COLUMNS = { PENDINGID_COLUMN, TYPE_COLUMN, + STATUS_COLUMN, WORDLISTID_COLUMN, LOCALE_COLUMN, DESCRIPTION_COLUMN, + LOCAL_FILENAME_COLUMN, REMOTE_FILENAME_COLUMN, DATE_COLUMN, CHECKSUM_COLUMN, + FILESIZE_COLUMN, VERSION_COLUMN, FORMATVERSION_COLUMN, FLAGS_COLUMN, + RAW_CHECKSUM_COLUMN, RETRY_COUNT_COLUMN }; + // List of all client table columns. + static final String[] CLIENT_TABLE_COLUMNS = { CLIENT_CLIENT_ID_COLUMN, + CLIENT_METADATA_URI_COLUMN, CLIENT_PENDINGID_COLUMN, FLAGS_COLUMN }; + // List of public columns returned to clients. Everything that is not in this list is + // private and implementation-dependent. + static final String[] DICTIONARIES_LIST_PUBLIC_COLUMNS = { STATUS_COLUMN, WORDLISTID_COLUMN, + LOCALE_COLUMN, DESCRIPTION_COLUMN, DATE_COLUMN, FILESIZE_COLUMN, VERSION_COLUMN }; + + // This class exhibits a singleton-like behavior by client ID, so it is getInstance'd + // and has a private c'tor. + private static TreeMap<String, MetadataDbHelper> sInstanceMap = null; + public static synchronized MetadataDbHelper getInstance(final Context context, + final String clientIdOrNull) { + // As a backward compatibility feature, null can be passed here to retrieve the "default" + // database. Before multi-client support, the dictionary packed used only one database + // and would not be able to handle several dictionary sets. Passing null here retrieves + // this legacy database. New clients should make sure to always pass a client ID so as + // to avoid conflicts. + final String clientId = null != clientIdOrNull ? clientIdOrNull : ""; + if (null == sInstanceMap) sInstanceMap = new TreeMap<>(); + MetadataDbHelper helper = sInstanceMap.get(clientId); + if (null == helper) { + helper = new MetadataDbHelper(context, clientId); + sInstanceMap.put(clientId, helper); + } + return helper; + } + private MetadataDbHelper(final Context context, final String clientId) { + super(context, + METADATA_DATABASE_NAME_STEM + (TextUtils.isEmpty(clientId) ? "" : "." + clientId), + null, CURRENT_METADATA_DATABASE_VERSION); + mContext = context; + mClientId = clientId; + } + + private final Context mContext; + private final String mClientId; + + /** + * Get the database itself. This always returns the same object for any client ID. If the + * client ID is null, a default database is returned for backward compatibility. Don't + * pass null for new calls. + * + * @param context the context to create the database from. This is ignored after the first call. + * @param clientId the client id to retrieve the database of. null for default (deprecated) + * @return the database. + */ + public static SQLiteDatabase getDb(final Context context, final String clientId) { + return getInstance(context, clientId).getWritableDatabase(); + } + + private void createClientTable(final SQLiteDatabase db) { + // The clients table only exists in the primary db, the one that has an empty client id + if (!TextUtils.isEmpty(mClientId)) return; + db.execSQL(METADATA_CREATE_CLIENT_TABLE); + final String defaultMetadataUri = mContext.getString(R.string.default_metadata_uri); + if (!TextUtils.isEmpty(defaultMetadataUri)) { + final ContentValues defaultMetadataValues = new ContentValues(); + defaultMetadataValues.put(CLIENT_CLIENT_ID_COLUMN, ""); + defaultMetadataValues.put(CLIENT_METADATA_URI_COLUMN, defaultMetadataUri); + defaultMetadataValues.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID); + db.insert(CLIENT_TABLE_NAME, null, defaultMetadataValues); + } + } + + /** + * Create the table and populate it with the resources found inside the apk. + * + * @see SQLiteOpenHelper#onCreate(SQLiteDatabase) + * + * @param db the database to create and populate. + */ + @Override + public void onCreate(final SQLiteDatabase db) { + db.execSQL(METADATA_TABLE_CREATE); + createClientTable(db); + } + + private static void addRawChecksumColumnUnlessPresent(final SQLiteDatabase db) { + try { + db.execSQL("SELECT " + RAW_CHECKSUM_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;"); + } catch (SQLiteException e) { + Log.i(TAG, "No " + RAW_CHECKSUM_COLUMN + " column : creating it"); + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RAW_CHECKSUM_COLUMN + " TEXT;"); + } + } + + private static void addRetryCountColumnUnlessPresent(final SQLiteDatabase db) { + try { + db.execSQL("SELECT " + RETRY_COUNT_COLUMN + " FROM " + + METADATA_TABLE_NAME + " LIMIT 0;"); + } catch (SQLiteException e) { + Log.i(TAG, "No " + RETRY_COUNT_COLUMN + " column : creating it"); + db.execSQL("ALTER TABLE " + METADATA_TABLE_NAME + " ADD COLUMN " + + RETRY_COUNT_COLUMN + " INTEGER DEFAULT " + DICTIONARY_RETRY_THRESHOLD + ";"); + } + } + + /** + * Upgrade the database. Upgrade from version 3 is supported. + * Version 3 has a DB named METADATA_DATABASE_NAME_STEM containing a table METADATA_TABLE_NAME. + * Version 6 and above has a DB named METADATA_DATABASE_NAME_STEM containing a + * table CLIENT_TABLE_NAME, and for each client a table called METADATA_TABLE_STEM + "." + the + * name of the client and contains a table METADATA_TABLE_NAME. + * For schemas, see the above create statements. The schemas have never changed so far. + * + * This method is called by the framework. See {@link SQLiteOpenHelper#onUpgrade} + * @param db The database we are upgrading + * @param oldVersion The old database version (the one on the disk) + * @param newVersion The new database version as supplied to the constructor of SQLiteOpenHelper + */ + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + if (METADATA_DATABASE_INITIAL_VERSION == oldVersion + && METADATA_DATABASE_VERSION_WITH_CLIENTID <= newVersion + && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { + // Upgrade from version METADATA_DATABASE_INITIAL_VERSION to version + // METADATA_DATABASE_VERSION_WITH_CLIENT_ID + // Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { + // Anyway in version 3 only the default table existed so the emptiness + // test should always be true, but better check to be sure. + createClientTable(db); + } + } else if (METADATA_DATABASE_VERSION_WITH_CLIENTID < newVersion + && CURRENT_METADATA_DATABASE_VERSION >= newVersion) { + // Here we drop the client table, so that all clients send us their information again. + // The client table contains the URL to hit to update the available dictionaries list, + // but the info about the dictionaries themselves is stored in the table called + // METADATA_TABLE_NAME and we want to keep it, so we only drop the client table. + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + // Only the default database should contain the client table, so we test for mClientId. + if (TextUtils.isEmpty(mClientId)) { + createClientTable(db); + } + } else { + // If we're not in the above case, either we are upgrading from an earlier versionCode + // and we should wipe the database, or we are handling a version we never heard about + // (can only be a bug) so it's safer to wipe the database. + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + onCreate(db); + } + // A rawChecksum column that did not exist in the previous versions was added that + // corresponds to the md5 checksum of the file after decompression/decryption. This is to + // strengthen the system against corrupted dictionary files. + // The most secure way to upgrade a database is to just test for the column presence, and + // add it if it's not there. + addRawChecksumColumnUnlessPresent(db); + + // A retry count column that did not exist in the previous versions was added that + // corresponds to the number of download & installation attempts that have been made + // in order to strengthen the system recovery from corrupted dictionary files. + // The most secure way to upgrade a database is to just test for the column presence, and + // add it if it's not there. + addRetryCountColumnUnlessPresent(db); + } + + /** + * Downgrade the database. This drops and recreates the table in all cases. + */ + @Override + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + // No matter what the numerical values of oldVersion and newVersion are, we know this + // is a downgrade (newVersion < oldVersion). There is no way to know what the future + // databases will look like, but we know it's extremely likely that it's okay to just + // drop the tables and start from scratch. Hence, we ignore the versions and just wipe + // everything we want to use. + if (oldVersion <= newVersion) { + Log.e(TAG, "onDowngrade database but new version is higher? " + oldVersion + " <= " + + newVersion); + } + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL("DROP TABLE IF EXISTS " + CLIENT_TABLE_NAME); + onCreate(db); + } + + /** + * Given a client ID, returns whether this client exists. + * + * @param context a context to open the database + * @param clientId the client ID to check + * @return true if the client is known, false otherwise + */ + public static boolean isClientKnown(final Context context, final String clientId) { + // If the client is known, they'll have a non-null metadata URI. An empty string is + // allowed as a metadata URI, if the client doesn't want any updates to happen. + return null != getMetadataUriAsString(context, clientId); + } + + private static final MetadataUriGetter sMetadataUriGetter = new MetadataUriGetter(); + + /** + * Returns the metadata URI as a string. + * + * If the client is not known, this will return null. If it is known, it will return + * the URI as a string. Note that the empty string is a valid value. + * + * @param context a context instance to open the database on + * @param clientId the ID of the client we want the metadata URI of + * @return the string representation of the URI + */ + public static String getMetadataUriAsString(final Context context, final String clientId) { + SQLiteDatabase defaultDb = MetadataDbHelper.getDb(context, null); + final Cursor cursor = defaultDb.query(MetadataDbHelper.CLIENT_TABLE_NAME, + new String[] { MetadataDbHelper.CLIENT_METADATA_URI_COLUMN }, + MetadataDbHelper.CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return null; + return sMetadataUriGetter.getUri(context, cursor.getString(0)); + } finally { + cursor.close(); + } + } + + /** + * Update the last metadata update time for all clients using a particular URI. + * + * This method searches for all clients using a particular URI and updates the last + * update time for this client. + * The current time is used as the latest update time. This saved date will be what + * is returned henceforth by {@link #getLastUpdateDateForClient(Context, String)}, + * until this method is called again. + * + * @param context a context instance to open the database on + * @param uri the metadata URI we just downloaded + */ + public static void saveLastUpdateTimeOfUri(final Context context, final String uri) { + PrivateLog.log("Save last update time of URI : " + uri + " " + System.currentTimeMillis()); + final ContentValues values = new ContentValues(); + values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()); + final SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return; + try { + if (!cursor.moveToFirst()) return; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + if (metadataUri.equals(uri)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + } + + /** + * Retrieves the last date at which we updated the metadata for this client. + * + * The returned date is in milliseconds from the EPOCH; this is the same unit as + * returned by {@link System#currentTimeMillis()}. + * + * @param context a context instance to open the database on + * @param clientId the client ID to get the latest update date of + * @return the last date at which this client was updated, as a long. + */ + public static long getLastUpdateDateForClient(final Context context, final String clientId) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, + CLIENT_CLIENT_ID_COLUMN + " = ?", + new String[] { null == clientId ? "" : clientId }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return 0; + return cursor.getLong(0); // Only one column, return it + } finally { + cursor.close(); + } + } + + /** + * Get the metadata download ID for a metadata URI. + * + * This will retrieve the download ID for the metadata file that has the passed URI. + * If this URI is not being downloaded right now, it will return NOT_AN_ID. + * + * @param context a context instance to open the database on + * @param uri the URI to retrieve the metadata download ID of + * @return the download id and start date, or null if the URL is not known + */ + public static DownloadIdAndStartDate getMetadataDownloadIdAndStartDateForURI( + final Context context, final String uri) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_PENDINGID_COLUMN, CLIENT_LAST_UPDATE_DATE_COLUMN }, + CLIENT_METADATA_URI_COLUMN + " = ?", new String[] { uri }, + null, null, null, null); + try { + if (!cursor.moveToFirst()) return null; + return new DownloadIdAndStartDate(cursor.getInt(0), cursor.getLong(1)); + } finally { + cursor.close(); + } + } + + public static long getOldestUpdateTime(final Context context) { + SQLiteDatabase defaultDb = getDb(context, null); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, + new String[] { CLIENT_LAST_UPDATE_DATE_COLUMN }, + null, null, null, null, null); + try { + if (!cursor.moveToFirst()) return 0; + final int columnIndex = 0; // Only one column queried + // Initialize the earliestTime to the largest possible value. + long earliestTime = Long.MAX_VALUE; // Almost 300 million years in the future + do { + final long thisTime = cursor.getLong(columnIndex); + earliestTime = Math.min(thisTime, earliestTime); + } while (cursor.moveToNext()); + return earliestTime; + } finally { + cursor.close(); + } + } + + /** + * Helper method to make content values to write into the database. + * @return content values with all the arguments put with the right column names. + */ + public static ContentValues makeContentValues(final int pendingId, final int type, + final int status, final String wordlistId, final String locale, + final String description, final String filename, final String url, final long date, + final String rawChecksum, final String checksum, final int retryCount, + final long filesize, final int version, final int formatVersion) { + final ContentValues result = new ContentValues(COLUMN_COUNT); + result.put(PENDINGID_COLUMN, pendingId); + result.put(TYPE_COLUMN, type); + result.put(WORDLISTID_COLUMN, wordlistId); + result.put(STATUS_COLUMN, status); + result.put(LOCALE_COLUMN, locale); + result.put(DESCRIPTION_COLUMN, description); + result.put(LOCAL_FILENAME_COLUMN, filename); + result.put(REMOTE_FILENAME_COLUMN, url); + result.put(DATE_COLUMN, date); + result.put(RAW_CHECKSUM_COLUMN, rawChecksum); + result.put(RETRY_COUNT_COLUMN, retryCount); + result.put(CHECKSUM_COLUMN, checksum); + result.put(FILESIZE_COLUMN, filesize); + result.put(VERSION_COLUMN, version); + result.put(FORMATVERSION_COLUMN, formatVersion); + result.put(FLAGS_COLUMN, 0); + return result; + } + + /** + * Helper method to fill in an incomplete ContentValues with default values. + * A wordlist ID and a locale are required, otherwise BadFormatException is thrown. + * @return the same object that was passed in, completed with default values. + */ + public static ContentValues completeWithDefaultValues(final ContentValues result) + throws BadFormatException { + if (null == result.get(WORDLISTID_COLUMN) || null == result.get(LOCALE_COLUMN)) { + throw new BadFormatException(); + } + // 0 for the pending id, because there is none + if (null == result.get(PENDINGID_COLUMN)) result.put(PENDINGID_COLUMN, 0); + // This is a binary blob of a dictionary + if (null == result.get(TYPE_COLUMN)) result.put(TYPE_COLUMN, TYPE_BULK); + // This word list is unknown, but it's present, else we wouldn't be here, so INSTALLED + if (null == result.get(STATUS_COLUMN)) result.put(STATUS_COLUMN, STATUS_INSTALLED); + // No description unless specified, because we can't guess it + if (null == result.get(DESCRIPTION_COLUMN)) result.put(DESCRIPTION_COLUMN, ""); + // File name - this is an asset, so it works as an already deleted file. + // hence, we need to supply a non-existent file name. Anything will + // do as long as it returns false when tested with File#exist(), and + // the empty string does not, so it's set to "_". + if (null == result.get(LOCAL_FILENAME_COLUMN)) result.put(LOCAL_FILENAME_COLUMN, "_"); + // No remote file name : this can't be downloaded. Unless specified. + if (null == result.get(REMOTE_FILENAME_COLUMN)) result.put(REMOTE_FILENAME_COLUMN, ""); + // 0 for the update date : 1970/1/1. Unless specified. + if (null == result.get(DATE_COLUMN)) result.put(DATE_COLUMN, 0); + // Raw checksum unknown unless specified + if (null == result.get(RAW_CHECKSUM_COLUMN)) result.put(RAW_CHECKSUM_COLUMN, ""); + // Retry column 0 unless specified + if (null == result.get(RETRY_COUNT_COLUMN)) result.put(RETRY_COUNT_COLUMN, + DICTIONARY_RETRY_THRESHOLD); + // Checksum unknown unless specified + if (null == result.get(CHECKSUM_COLUMN)) result.put(CHECKSUM_COLUMN, ""); + // No filesize unless specified + if (null == result.get(FILESIZE_COLUMN)) result.put(FILESIZE_COLUMN, 0); + // Smallest possible version unless specified + if (null == result.get(VERSION_COLUMN)) result.put(VERSION_COLUMN, 1); + // Assume current format unless specified + if (null == result.get(FORMATVERSION_COLUMN)) + result.put(FORMATVERSION_COLUMN, UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION); + // No flags unless specified + if (null == result.get(FLAGS_COLUMN)) result.put(FLAGS_COLUMN, 0); + return result; + } + + /** + * Reads a column in a Cursor as a String and stores it in a ContentValues object. + * @param result the ContentValues object to store the result in. + * @param cursor the Cursor to read the column from. + * @param columnId the column ID to read. + */ + private static void putStringResult(ContentValues result, Cursor cursor, String columnId) { + result.put(columnId, cursor.getString(cursor.getColumnIndex(columnId))); + } + + /** + * Reads a column in a Cursor as an int and stores it in a ContentValues object. + * @param result the ContentValues object to store the result in. + * @param cursor the Cursor to read the column from. + * @param columnId the column ID to read. + */ + private static void putIntResult(ContentValues result, Cursor cursor, String columnId) { + result.put(columnId, cursor.getInt(cursor.getColumnIndex(columnId))); + } + + private static ContentValues getFirstLineAsContentValues(final Cursor cursor) { + final ContentValues result; + if (cursor.moveToFirst()) { + result = new ContentValues(COLUMN_COUNT); + putIntResult(result, cursor, PENDINGID_COLUMN); + putIntResult(result, cursor, TYPE_COLUMN); + putIntResult(result, cursor, STATUS_COLUMN); + putStringResult(result, cursor, WORDLISTID_COLUMN); + putStringResult(result, cursor, LOCALE_COLUMN); + putStringResult(result, cursor, DESCRIPTION_COLUMN); + putStringResult(result, cursor, LOCAL_FILENAME_COLUMN); + putStringResult(result, cursor, REMOTE_FILENAME_COLUMN); + putIntResult(result, cursor, DATE_COLUMN); + putStringResult(result, cursor, RAW_CHECKSUM_COLUMN); + putStringResult(result, cursor, CHECKSUM_COLUMN); + putIntResult(result, cursor, RETRY_COUNT_COLUMN); + putIntResult(result, cursor, FILESIZE_COLUMN); + putIntResult(result, cursor, VERSION_COLUMN); + putIntResult(result, cursor, FORMATVERSION_COLUMN); + putIntResult(result, cursor, FLAGS_COLUMN); + if (cursor.moveToNext()) { + // TODO: print the second level of the stack to the log so that we know + // in which code path the error happened + Log.e(TAG, "Several SQL results when we expected only one!"); + } + } else { + result = null; + } + return result; + } + + /** + * Gets the info about as specific download, indexed by its DownloadManager ID. + * @param db the database to get the information from. + * @param id the DownloadManager id. + * @return metadata about this download. This returns all columns in the database. + */ + public static ContentValues getContentValuesByPendingId(final SQLiteDatabase db, + final long id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + PENDINGID_COLUMN + "= ?", + new String[] { Long.toString(id) }, + null, null, null); + if (null == cursor) { + return null; + } + try { + // There should never be more than one result. If because of some bug there are, + // returning only one result is the right thing to do, because we couldn't handle + // several anyway and we should still handle one. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the info about an installed OR deleting word list with a specified id. + * + * Basically, this is the word list that we want to return to Kelar Keyboard when + * it asks for a specific id. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @return the metadata about this word list. + */ + public static ContentValues getInstalledOrDeletingWordListContentValuesByWordListId( + final SQLiteDatabase db, final String id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "=? AND (" + STATUS_COLUMN + "=? OR " + STATUS_COLUMN + "=?)", + new String[] { id, Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING) }, + null, null, null); + if (null == cursor) { + return null; + } + try { + // There should only be one result, but if there are several, we can't tell which + // is the best, so we just return the first one. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Given a specific download ID, return records for all pending downloads across all clients. + * + * If several clients use the same metadata URL, we know to only download it once, and + * dispatch the update process across all relevant clients when the download ends. This means + * several clients may share a single download ID if they share a metadata URI. + * The dispatching is done in + * {@link UpdateHandler#downloadFinished(Context, android.content.Intent)}, which + * finds out about the list of relevant clients by calling this method. + * + * @param context a context instance to open the databases + * @param downloadId the download ID to query about + * @return the list of records. Never null, but may be empty. + */ + public static ArrayList<DownloadRecord> getDownloadRecordsForDownloadId(final Context context, + final long downloadId) { + final SQLiteDatabase defaultDb = getDb(context, ""); + final ArrayList<DownloadRecord> results = new ArrayList<>(); + final Cursor cursor = defaultDb.query(CLIENT_TABLE_NAME, CLIENT_TABLE_COLUMNS, + null, null, null, null, null); + try { + if (!cursor.moveToFirst()) return results; + final int clientIdIndex = cursor.getColumnIndex(CLIENT_CLIENT_ID_COLUMN); + final int pendingIdColumn = cursor.getColumnIndex(CLIENT_PENDINGID_COLUMN); + do { + final long pendingId = cursor.getInt(pendingIdColumn); + final String clientId = cursor.getString(clientIdIndex); + if (pendingId == downloadId) { + results.add(new DownloadRecord(clientId, null)); + } + final ContentValues valuesForThisClient = + getContentValuesByPendingId(getDb(context, clientId), downloadId); + if (null != valuesForThisClient) { + results.add(new DownloadRecord(clientId, valuesForThisClient)); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + return results; + } + + /** + * Gets the info about a specific word list. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @param version the word list version. + * @return the metadata about this word list. + */ + @Nullable + public static ContentValues getContentValuesByWordListId(final SQLiteDatabase db, + final String id, final int version) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "= ? AND " + VERSION_COLUMN + "= ? AND " + + FORMATVERSION_COLUMN + "<= ?", + new String[] + { id, + Integer.toString(version), + Integer.toString(UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION) + }, + null /* groupBy */, + null /* having */, + FORMATVERSION_COLUMN + " DESC"/* orderBy */); + if (null == cursor) { + return null; + } + try { + // This is a lookup by primary key, so there can't be more than one result. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the info about the latest word list with an id. + * + * @param db the database to get the information from. + * @param id the word list ID. + * @return the metadata about the word list with this id and the latest version number. + */ + public static ContentValues getContentValuesOfLatestAvailableWordlistById( + final SQLiteDatabase db, final String id) { + final Cursor cursor = db.query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + WORDLISTID_COLUMN + "= ?", + new String[] { id }, null, null, VERSION_COLUMN + " DESC", "1"); + if (null == cursor) { + return null; + } + try { + // Return the first result from the list of results. + return getFirstLineAsContentValues(cursor); + } finally { + cursor.close(); + } + } + + /** + * Gets the current metadata about INSTALLED, AVAILABLE or DELETING dictionaries. + * + * This odd method is tailored to the needs of + * DictionaryProvider#getDictionaryWordListsForContentUri, which needs the word list if + * it is: + * - INSTALLED: this should be returned to LatinIME if the file is still inside the dictionary + * pack, so that it can be copied. If the file is not there, it's been copied already and should + * not be returned, so getDictionaryWordListsForContentUri takes care of this. + * - DELETING: this should be returned to LatinIME so that it can actually delete the file. + * - AVAILABLE: this should not be returned, but should be checked for auto-installation. + * + * @param context the context for getting the database. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor with metadata about usable dictionaries. + */ + public static Cursor queryInstalledOrDeletingOrAvailableDictionaryMetadata( + final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ? OR " + STATUS_COLUMN + " = ?", + new String[] { Integer.toString(STATUS_INSTALLED), + Integer.toString(STATUS_DELETING), + Integer.toString(STATUS_AVAILABLE) }, + null, null, LOCALE_COLUMN); + return results; + } + + /** + * Gets the current metadata about all dictionaries. + * + * This will retrieve the metadata about all dictionaries, including + * older files, or files not yet downloaded. + * + * @param context the context for getting the database. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor with metadata about usable dictionaries. + */ + public static Cursor queryCurrentMetadata(final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + METADATA_TABLE_COLUMNS, null, null, null, null, LOCALE_COLUMN); + return results; + } + + /** + * Gets the list of all dictionaries known to the dictionary provider, with only public columns. + * + * This will retrieve information about all known dictionaries, and their status. As such, + * it will also return information about dictionaries on the server that have not been + * downloaded yet, but may be requested. + * This only returns public columns. It does not populate internal columns in the returned + * cursor. + * The value returned by this method is intended to be good to be returned directly for a + * request of the list of dictionaries by a client. + * + * @param context the context to read the database from. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return a cursor that lists all available dictionaries and their metadata. + */ + public static Cursor queryDictionaries(final Context context, final String clientId) { + // If clientId is null, we get the defaut DB (see #getInstance() for more about this) + final Cursor results = getDb(context, clientId).query(METADATA_TABLE_NAME, + DICTIONARIES_LIST_PUBLIC_COLUMNS, + // Filter out empty locales so as not to return auxiliary data, like a + // data line for downloading metadata: + MetadataDbHelper.LOCALE_COLUMN + " != ?", new String[] {""}, + // TODO: Reinstate the following code for bulk, then implement partial updates + /* MetadataDbHelper.TYPE_COLUMN + " = ?", + new String[] { Integer.toString(MetadataDbHelper.TYPE_BULK) }, */ + null, null, LOCALE_COLUMN); + return results; + } + + /** + * Deletes all data associated with a client. + * + * @param context the context for opening the database + * @param clientId the ID of the client to delete. + * @return true if the client was successfully deleted, false otherwise. + */ + public static boolean deleteClient(final Context context, final String clientId) { + // Remove all metadata associated with this client + final SQLiteDatabase db = getDb(context, clientId); + db.execSQL("DROP TABLE IF EXISTS " + METADATA_TABLE_NAME); + db.execSQL(METADATA_TABLE_CREATE); + // Remove this client's entry in the clients table + final SQLiteDatabase defaultDb = getDb(context, ""); + if (0 == defaultDb.delete(CLIENT_TABLE_NAME, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId })) { + return false; + } + return true; + } + + /** + * Updates information relative to a specific client. + * + * Updatable information includes the metadata URI and the additional ID column. It may be + * expanded in the future. + * The passed values must include a client ID in the key CLIENT_CLIENT_ID_COLUMN, and it must + * be equal to the string passed as an argument for clientId. It may not be empty. + * The passed values must also include a non-null metadata URI in the + * CLIENT_METADATA_URI_COLUMN column, as well as a non-null additional ID in the + * CLIENT_METADATA_ADDITIONAL_ID_COLUMN. Both these strings may be empty. + * If any of the above is not complied with, this function returns without updating data. + * + * @param context the context, to open the database + * @param clientId the ID of the client to update + * @param values the values to update. Must conform to the protocol (see above) + */ + public static void updateClientInfo(final Context context, final String clientId, + final ContentValues values) { + // Validity check the content values + final String valuesClientId = values.getAsString(CLIENT_CLIENT_ID_COLUMN); + final String valuesMetadataUri = values.getAsString(CLIENT_METADATA_URI_COLUMN); + final String valuesMetadataAdditionalId = + values.getAsString(CLIENT_METADATA_ADDITIONAL_ID_COLUMN); + // Empty string is a valid client ID, but external apps may not configure it, so disallow + // both null and empty string. + // Empty string is a valid metadata URI if the client does not want updates, so allow + // empty string but disallow null. + // Empty string is a valid additional ID so allow empty string but disallow null. + if (TextUtils.isEmpty(valuesClientId) || null == valuesMetadataUri + || null == valuesMetadataAdditionalId) { + // We need all these columns to be filled in + DebugLogUtils.l("Missing parameter for updateClientInfo"); + return; + } + if (!clientId.equals(valuesClientId)) { + // Mismatch! The client violates the protocol. + DebugLogUtils.l("Received an updateClientInfo request for ", clientId, + " but the values " + "contain a different ID : ", valuesClientId); + return; + } + // Default value for a pending ID is NOT_AN_ID + values.put(CLIENT_PENDINGID_COLUMN, UpdateHandler.NOT_AN_ID); + final SQLiteDatabase defaultDb = getDb(context, ""); + if (-1 == defaultDb.insert(CLIENT_TABLE_NAME, null, values)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } + + /** + * Retrieves the list of existing client IDs. + * @param context the context to open the database + * @return a cursor containing only one column, and one client ID per line. + */ + public static Cursor queryClientIds(final Context context) { + return getDb(context, null).query(CLIENT_TABLE_NAME, + new String[] { CLIENT_CLIENT_ID_COLUMN }, null, null, null, null, null); + } + + /** + * Register a download ID for a specific metadata URI. + * + * This method should be called when a download for a metadata URI is starting. It will + * search for all clients using this metadata URI and will register for each of them + * the download ID into the database for later retrieval by + * {@link #getDownloadRecordsForDownloadId(Context, long)}. + * + * @param context a context for opening databases + * @param uri the metadata URI + * @param downloadId the download ID + */ + public static void registerMetadataDownloadId(final Context context, final String uri, + final long downloadId) { + final ContentValues values = new ContentValues(); + values.put(CLIENT_PENDINGID_COLUMN, downloadId); + values.put(CLIENT_LAST_UPDATE_DATE_COLUMN, System.currentTimeMillis()); + final SQLiteDatabase defaultDb = getDb(context, ""); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return; + try { + if (!cursor.moveToFirst()) return; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + if (metadataUri.equals(uri)) { + defaultDb.update(CLIENT_TABLE_NAME, values, + CLIENT_CLIENT_ID_COLUMN + " = ?", new String[] { clientId }); + } + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + } + + /** + * Marks a downloading entry as having successfully downloaded and being installed. + * + * The metadata database contains information about ongoing processes, typically ongoing + * downloads. This marks such an entry as having finished and having installed successfully, + * so it becomes INSTALLED. + * + * @param db the metadata database. + * @param r content values about the entry to mark as processed. + */ + public static void markEntryAsFinishedDownloadingAndInstalled(final SQLiteDatabase db, + final ContentValues r) { + switch (r.getAsInteger(TYPE_COLUMN)) { + case TYPE_BULK: + DebugLogUtils.l("Ended processing a wordlist"); + // Updating a bulk word list is a three-step operation: + // - Add the new entry to the table + // - Remove the old entry from the table + // - Erase the old file + // We start by gathering the names of the files we should delete. + final List<String> filenames = new LinkedList<>(); + final Cursor c = db.query(METADATA_TABLE_NAME, + new String[] { LOCAL_FILENAME_COLUMN }, + LOCALE_COLUMN + " = ? AND " + + WORDLISTID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", + new String[] { r.getAsString(LOCALE_COLUMN), + r.getAsString(WORDLISTID_COLUMN), + Integer.toString(STATUS_INSTALLED) }, + null, null, null); + try { + if (c.moveToFirst()) { + // There should never be more than one file, but if there are, it's a bug + // and we should remove them all. I think it might happen if the power of + // the phone is suddenly cut during an update. + final int filenameIndex = c.getColumnIndex(LOCAL_FILENAME_COLUMN); + do { + DebugLogUtils.l("Setting for removal", c.getString(filenameIndex)); + filenames.add(c.getString(filenameIndex)); + } while (c.moveToNext()); + } + } finally { + c.close(); + } + r.put(STATUS_COLUMN, STATUS_INSTALLED); + db.beginTransactionNonExclusive(); + // Delete all old entries. There should never be any stalled entries, but if + // there are, this deletes them. + db.delete(METADATA_TABLE_NAME, + WORDLISTID_COLUMN + " = ?", + new String[] { r.getAsString(WORDLISTID_COLUMN) }); + db.insert(METADATA_TABLE_NAME, null, r); + db.setTransactionSuccessful(); + db.endTransaction(); + for (String filename : filenames) { + try { + final File f = new File(filename); + f.delete(); + } catch (SecurityException e) { + // No permissions to delete. Um. Can't do anything. + } // I don't think anything else can be thrown + } + break; + default: + // Unknown type: do nothing. + break; + } + } + + /** + * Removes a downloading entry from the database. + * + * This is invoked when a download fails. Either we tried to download, but + * we received a permanent failure and we should remove it, or we got manually + * cancelled and we should leave it at that. + * + * @param db the metadata database. + * @param id the DownloadManager id of the file. + */ + public static void deleteDownloadingEntry(final SQLiteDatabase db, final long id) { + db.delete(METADATA_TABLE_NAME, PENDINGID_COLUMN + " = ? AND " + STATUS_COLUMN + " = ?", + new String[] { Long.toString(id), Integer.toString(STATUS_DOWNLOADING) }); + } + + /** + * Forcefully removes an entry from the database. + * + * This is invoked when a file is broken. The file has been downloaded, but Android + * Keyboard is telling us it could not open it. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void deleteEntry(final SQLiteDatabase db, final String id, final int version) { + db.delete(METADATA_TABLE_NAME, WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + } + + /** + * Internal method that sets the current status of an entry of the database. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @param status the status to set the word list to. + * @param downloadId an optional download id to write, or NOT_A_DOWNLOAD_ID + */ + private static void markEntryAs(final SQLiteDatabase db, final String id, + final int version, final int status, final long downloadId) { + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); + values.put(STATUS_COLUMN, status); + if (NOT_A_DOWNLOAD_ID != downloadId) { + values.put(MetadataDbHelper.PENDINGID_COLUMN, downloadId); + } + db.update(METADATA_TABLE_NAME, values, + WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + } + + /** + * Writes the status column for the wordlist with this id as enabled. Typically this + * means the word list is currently disabled and we want to set its status to INSTALLED. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsEnabled(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_INSTALLED, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the status column for the wordlist with this id as disabled. Typically this + * means the word list is currently installed and we want to set its status to DISABLED. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsDisabled(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_DISABLED, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the status column for the wordlist with this id as available. This happens for + * example when a word list has been deleted but can be downloaded again. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsAvailable(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_AVAILABLE, NOT_A_DOWNLOAD_ID); + } + + /** + * Writes the designated word list as downloadable, alongside with its download id. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @param downloadId the download id. + */ + public static void markEntryAsDownloading(final SQLiteDatabase db, final String id, + final int version, final long downloadId) { + markEntryAs(db, id, version, STATUS_DOWNLOADING, downloadId); + } + + /** + * Writes the designated word list as deleting. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + */ + public static void markEntryAsDeleting(final SQLiteDatabase db, final String id, + final int version) { + markEntryAs(db, id, version, STATUS_DELETING, NOT_A_DOWNLOAD_ID); + } + + /** + * Checks retry counts and marks the word list as retrying if retry is possible. + * + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @return {@code true} if the retry is possible. + */ + public static boolean maybeMarkEntryAsRetrying(final SQLiteDatabase db, final String id, + final int version) { + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, id, version); + int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); + if (retryCount > 1) { + values.put(STATUS_COLUMN, STATUS_RETRYING); + values.put(RETRY_COUNT_COLUMN, retryCount - 1); + db.update(METADATA_TABLE_NAME, values, + WORDLISTID_COLUMN + " = ? AND " + VERSION_COLUMN + " = ?", + new String[] { id, Integer.toString(version) }); + return true; + } + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java new file mode 100644 index 000000000..0dcd33e2d --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to easy up manipulation of dictionary pack metadata. + */ +public class MetadataHandler { + + public static final String TAG = MetadataHandler.class.getSimpleName(); + + // The canonical file name for metadata. This is not the name of a real file on the + // device, but a symbolic name used in the database and in metadata handling. It is never + // tested against, only used for human-readability as the file name for the metadata. + public static final String METADATA_FILENAME = "metadata.json"; + + /** + * Reads the data from the cursor and store it in metadata objects. + * @param results the cursor to read data from. + * @return the constructed list of wordlist metadata. + */ + private static List<WordListMetadata> makeMetadataObject(final Cursor results) { + final ArrayList<WordListMetadata> buildingMetadata = new ArrayList<>(); + if (null != results && results.moveToFirst()) { + final int localeColumn = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN); + final int typeColumn = results.getColumnIndex(MetadataDbHelper.TYPE_COLUMN); + final int descriptionColumn = + results.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN); + final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN); + final int updateIndex = results.getColumnIndex(MetadataDbHelper.DATE_COLUMN); + final int fileSizeIndex = results.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN); + final int rawChecksumIndex = + results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN); + final int checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_COLUMN); + final int retryCountIndex = results.getColumnIndex(MetadataDbHelper.RETRY_COUNT_COLUMN); + final int localFilenameIndex = + results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final int remoteFilenameIndex = + results.getColumnIndex(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + final int versionIndex = results.getColumnIndex(MetadataDbHelper.VERSION_COLUMN); + final int formatVersionIndex = + results.getColumnIndex(MetadataDbHelper.FORMATVERSION_COLUMN); + do { + buildingMetadata.add(new WordListMetadata(results.getString(idIndex), + results.getInt(typeColumn), + results.getString(descriptionColumn), + results.getLong(updateIndex), + results.getLong(fileSizeIndex), + results.getString(rawChecksumIndex), + results.getString(checksumIndex), + results.getInt(retryCountIndex), + results.getString(localFilenameIndex), + results.getString(remoteFilenameIndex), + results.getInt(versionIndex), + results.getInt(formatVersionIndex), + 0, results.getString(localeColumn))); + } while (results.moveToNext()); + } + return Collections.unmodifiableList(buildingMetadata); + } + + /** + * Gets the whole metadata, for installed and not installed dictionaries. + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated) + * @return The current metadata. + */ + public static List<WordListMetadata> getCurrentMetadata(final Context context, + final String clientId) { + // If clientId is null, we get a cursor on the default database (see + // MetadataDbHelper#getInstance() for more on this) + final Cursor results = MetadataDbHelper.queryCurrentMetadata(context, clientId); + // If null, we should return makeMetadataObject(null), so we go through. + try { + return makeMetadataObject(results); + } finally { + if (null != results) { + results.close(); + } + } + } + + /** + * Gets the metadata, for a specific dictionary. + * + * @param context The context to open files over. + * @param clientId the client id for retrieving the database. null for default (deprecated). + * @param wordListId the word list ID. + * @param version the word list version. + * @return the current metaData + */ + public static WordListMetadata getCurrentMetadataForWordList(final Context context, + final String clientId, final String wordListId, final int version) { + final ContentValues contentValues = MetadataDbHelper.getContentValuesByWordListId( + MetadataDbHelper.getDb(context, clientId), wordListId, version); + if (contentValues == null) { + // TODO: Figure out why this would happen. + // Check if this happens when the metadata gets updated in the background. + Log.e(TAG, String.format( "Unable to find the current metadata for wordlist " + + "(clientId=%s, wordListId=%s, version=%d) on the database", + clientId, wordListId, version)); + return null; + } + return WordListMetadata.createFromContentValues(contentValues); + } + + /** + * Read metadata from a stream. + * @param input The stream to read from. + * @return The read metadata. + * @throws IOException if the input stream cannot be read + * @throws BadFormatException if the stream is not in a known format + */ + public static List<WordListMetadata> readMetadata(final InputStreamReader input) + throws IOException, BadFormatException { + return MetadataParser.parseMetadata(input); + } + + /** + * Finds a single WordListMetadata inside a whole metadata chunk. + * + * Searches through the whole passed metadata for the first WordListMetadata associated + * with the passed ID. If several metadata chunks with the same id are found, it will + * always return the one with the bigger FormatVersion that is less or equal than the + * maximum supported format version (as listed in UpdateHandler). + * This will NEVER return the metadata with a FormatVersion bigger than what is supported, + * even if it is the only word list with this ID. + * + * @param metadata the metadata to search into. + * @param id the word list ID of the metadata to find. + * @return the associated metadata, or null if not found. + */ + public static WordListMetadata findWordListById(final List<WordListMetadata> metadata, + final String id) { + WordListMetadata bestWordList = null; + int bestFormatVersion = Integer.MIN_VALUE; // To be sure we can't be inadvertently smaller + for (WordListMetadata wordList : metadata) { + if (id.equals(wordList.mId) + && wordList.mFormatVersion <= UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION + && wordList.mFormatVersion > bestFormatVersion) { + bestWordList = wordList; + bestFormatVersion = wordList.mFormatVersion; + } + } + // If we didn't find any match we'll return null. + return bestWordList; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java new file mode 100644 index 000000000..131667f87 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.text.TextUtils; +import android.util.JsonReader; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; + +/** + * Helper class containing functions to parse the dictionary metadata. + */ +public class MetadataParser { + + // Name of the fields in the JSON-formatted file. + private static final String ID_FIELD_NAME = MetadataDbHelper.WORDLISTID_COLUMN; + private static final String LOCALE_FIELD_NAME = "locale"; + private static final String DESCRIPTION_FIELD_NAME = MetadataDbHelper.DESCRIPTION_COLUMN; + private static final String UPDATE_FIELD_NAME = "update"; + private static final String FILESIZE_FIELD_NAME = MetadataDbHelper.FILESIZE_COLUMN; + private static final String RAW_CHECKSUM_FIELD_NAME = MetadataDbHelper.RAW_CHECKSUM_COLUMN; + private static final String CHECKSUM_FIELD_NAME = MetadataDbHelper.CHECKSUM_COLUMN; + private static final String REMOTE_FILENAME_FIELD_NAME = + MetadataDbHelper.REMOTE_FILENAME_COLUMN; + private static final String VERSION_FIELD_NAME = MetadataDbHelper.VERSION_COLUMN; + private static final String FORMATVERSION_FIELD_NAME = MetadataDbHelper.FORMATVERSION_COLUMN; + + /** + * Parse one JSON-formatted word list metadata. + * @param reader the reader containing the data. + * @return a WordListMetadata object from the parsed data. + * @throws IOException if the underlying reader throws IOException during reading. + */ + private static WordListMetadata parseOneWordList(final JsonReader reader) + throws IOException, BadFormatException { + final TreeMap<String, String> arguments = new TreeMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String name = reader.nextName(); + if (!TextUtils.isEmpty(name)) { + arguments.put(name, reader.nextString()); + } + } + reader.endObject(); + if (TextUtils.isEmpty(arguments.get(ID_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(LOCALE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(DESCRIPTION_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(UPDATE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(FILESIZE_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(CHECKSUM_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(REMOTE_FILENAME_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(VERSION_FIELD_NAME)) + || TextUtils.isEmpty(arguments.get(FORMATVERSION_FIELD_NAME))) { + throw new BadFormatException(arguments.toString()); + } + // TODO: need to find out whether it's bulk or update + // The null argument is the local file name, which is not known at this time and will + // be decided later. + return new WordListMetadata( + arguments.get(ID_FIELD_NAME), + MetadataDbHelper.TYPE_BULK, + arguments.get(DESCRIPTION_FIELD_NAME), + Long.parseLong(arguments.get(UPDATE_FIELD_NAME)), + Long.parseLong(arguments.get(FILESIZE_FIELD_NAME)), + arguments.get(RAW_CHECKSUM_FIELD_NAME), + arguments.get(CHECKSUM_FIELD_NAME), + MetadataDbHelper.DICTIONARY_RETRY_THRESHOLD /* retryCount */, + null, + arguments.get(REMOTE_FILENAME_FIELD_NAME), + Integer.parseInt(arguments.get(VERSION_FIELD_NAME)), + Integer.parseInt(arguments.get(FORMATVERSION_FIELD_NAME)), + 0, arguments.get(LOCALE_FIELD_NAME)); + } + + /** + * Parses metadata in the JSON format. + * @param input a stream reader expected to contain JSON formatted metadata. + * @return dictionary metadata, as an array of WordListMetadata objects. + * @throws IOException if the underlying reader throws IOException during reading. + * @throws BadFormatException if the data was not in the expected format. + */ + public static List<WordListMetadata> parseMetadata(final InputStreamReader input) + throws IOException, BadFormatException { + JsonReader reader = new JsonReader(input); + final ArrayList<WordListMetadata> readInfo = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + final WordListMetadata thisMetadata = parseOneWordList(reader); + if (!TextUtils.isEmpty(thisMetadata.mLocale)) + readInfo.add(thisMetadata); + } + return Collections.unmodifiableList(readInfo); + } + +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java b/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java new file mode 100644 index 000000000..e8a79f6ca --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java @@ -0,0 +1,29 @@ +/* + * 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 org.kelar.inputmethod.dictionarypack; + +import android.content.Context; + +/** + * Helper to get the metadata URI from its base URI. + */ +@SuppressWarnings("unused") +public class MetadataUriGetter { + public static String getUri(final Context context, final String baseUri) { + return baseUri; + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java b/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java new file mode 100644 index 000000000..227b4831c --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Class to keep long-term log. This is inactive in production, and is only for debug purposes. + */ +public class PrivateLog { + + public static final boolean DEBUG = DictionaryProvider.DEBUG; + + private static final String LOG_DATABASE_NAME = "log"; + private static final String LOG_TABLE_NAME = "log"; + private static final int LOG_DATABASE_VERSION = 1; + + private static final String COLUMN_DATE = "date"; + private static final String COLUMN_EVENT = "event"; + + private static final String LOG_TABLE_CREATE = "CREATE TABLE " + LOG_TABLE_NAME + " (" + + COLUMN_DATE + " TEXT," + + COLUMN_EVENT + " TEXT);"; + + static final SimpleDateFormat sDateFormat = new SimpleDateFormat( + "yyyy/MM/dd HH:mm:ss", Locale.ROOT); + + private static PrivateLog sInstance = new PrivateLog(); + private static DebugHelper sDebugHelper = null; + + private PrivateLog() { + } + + public static synchronized PrivateLog getInstance(final Context context) { + if (!DEBUG) return sInstance; + synchronized(PrivateLog.class) { + if (sDebugHelper == null) { + sDebugHelper = new DebugHelper(context); + } + return sInstance; + } + } + + static class DebugHelper extends SQLiteOpenHelper { + + DebugHelper(final Context context) { + super(context, LOG_DATABASE_NAME, null, LOG_DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + if (!DEBUG) return; + db.execSQL(LOG_TABLE_CREATE); + insert(db, "Created table"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (!DEBUG) return; + // Remove all data. + db.execSQL("DROP TABLE IF EXISTS " + LOG_TABLE_NAME); + onCreate(db); + insert(db, "Upgrade finished"); + } + + static void insert(SQLiteDatabase db, String event) { + if (!DEBUG) return; + final ContentValues c = new ContentValues(2); + c.put(COLUMN_DATE, sDateFormat.format(new Date(System.currentTimeMillis()))); + c.put(COLUMN_EVENT, event); + db.insert(LOG_TABLE_NAME, null, c); + } + + } + + public static void log(String event) { + if (!DEBUG) return; + final SQLiteDatabase l = sDebugHelper.getWritableDatabase(); + DebugHelper.insert(l, event); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java b/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java new file mode 100644 index 000000000..6690a79c1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +/** + * A simple interface to report problems. + */ +public interface ProblemReporter { + public void report(Exception e); +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java b/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java new file mode 100644 index 000000000..c2c785560 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java @@ -0,0 +1,1082 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.app.DownloadManager; +import android.app.DownloadManager.Query; +import android.app.DownloadManager.Request; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.makedict.FormatSpec; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import javax.annotation.Nullable; + +/** + * Handler for the update process. + * + * This class is in charge of coordinating the update process for the various dictionaries + * stored in the dictionary pack. + */ +public final class UpdateHandler { + static final String TAG = "DictionaryProvider:" + UpdateHandler.class.getSimpleName(); + private static final boolean DEBUG = DictionaryProvider.DEBUG; + + // Used to prevent trying to read the id of the downloaded file before it is written + static final Object sSharedIdProtector = new Object(); + + // Value used to mean this is not a real DownloadManager downloaded file id + // DownloadManager uses as an ID numbers returned out of an AUTOINCREMENT column + // in SQLite, so it should never return anything < 0. + public static final int NOT_AN_ID = -1; + public static final int MAXIMUM_SUPPORTED_FORMAT_VERSION = + FormatSpec.MAXIMUM_SUPPORTED_STATIC_VERSION; + + // Arbitrary. Probably good if it's a power of 2, and a couple thousand bytes long. + private static final int FILE_COPY_BUFFER_SIZE = 8192; + + // Table fixed values for metadata / downloads + final static String METADATA_NAME = "metadata"; + final static int METADATA_TYPE = 0; + final static int WORDLIST_TYPE = 1; + + // Suffix for generated dictionary files + private static final String DICT_FILE_SUFFIX = ".dict"; + // Name of the category for the main dictionary + public static final String MAIN_DICTIONARY_CATEGORY = "main"; + + public static final String TEMP_DICT_FILE_SUB = "___"; + + // The id for the "dictionary available" notification. + static final int DICT_AVAILABLE_NOTIFICATION_ID = 1; + + /** + * An interface for UIs or services that want to know when something happened. + * + * This is chiefly used by the dictionary manager UI. + */ + public interface UpdateEventListener { + void downloadedMetadata(boolean succeeded); + void wordListDownloadFinished(String wordListId, boolean succeeded); + void updateCycleCompleted(); + } + + /** + * The list of currently registered listeners. + */ + private static List<UpdateEventListener> sUpdateEventListeners + = Collections.synchronizedList(new LinkedList<UpdateEventListener>()); + + /** + * Register a new listener to be notified of updates. + * + * Don't forget to call unregisterUpdateEventListener when done with it, or + * it will leak the register. + */ + public static void registerUpdateEventListener(final UpdateEventListener listener) { + sUpdateEventListeners.add(listener); + } + + /** + * Unregister a previously registered listener. + */ + public static void unregisterUpdateEventListener(final UpdateEventListener listener) { + sUpdateEventListeners.remove(listener); + } + + private static final String DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY = "downloadOverMetered"; + + /** + * Write the DownloadManager ID of the currently downloading metadata to permanent storage. + * + * @param context to open shared prefs + * @param uri the uri of the metadata + * @param downloadId the id returned by DownloadManager + */ + private static void writeMetadataDownloadId(final Context context, final String uri, + final long downloadId) { + MetadataDbHelper.registerMetadataDownloadId(context, uri, downloadId); + } + + public static final int DOWNLOAD_OVER_METERED_SETTING_UNKNOWN = 0; + public static final int DOWNLOAD_OVER_METERED_ALLOWED = 1; + public static final int DOWNLOAD_OVER_METERED_DISALLOWED = 2; + + /** + * Sets the setting that tells us whether we may download over a metered connection. + */ + public static void setDownloadOverMeteredSetting(final Context context, + final boolean shouldDownloadOverMetered) { + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, shouldDownloadOverMetered + ? DOWNLOAD_OVER_METERED_ALLOWED : DOWNLOAD_OVER_METERED_DISALLOWED); + editor.apply(); + } + + /** + * Gets the setting that tells us whether we may download over a metered connection. + * + * This returns one of the constants above. + */ + public static int getDownloadOverMeteredSetting(final Context context) { + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + final int setting = prefs.getInt(DOWNLOAD_OVER_METERED_SETTING_PREFS_KEY, + DOWNLOAD_OVER_METERED_SETTING_UNKNOWN); + return setting; + } + + /** + * Download latest metadata from the server through DownloadManager for all known clients + * @param context The context for retrieving resources + * @return true if an update successfully started, false otherwise. + */ + public static boolean tryUpdate(final Context context) { + // TODO: loop through all clients instead of only doing the default one. + final TreeSet<String> uris = new TreeSet<>(); + final Cursor cursor = MetadataDbHelper.queryClientIds(context); + if (null == cursor) return false; + try { + if (!cursor.moveToFirst()) return false; + do { + final String clientId = cursor.getString(0); + final String metadataUri = + MetadataDbHelper.getMetadataUriAsString(context, clientId); + PrivateLog.log("Update for clientId " + DebugLogUtils.s(clientId)); + DebugLogUtils.l("Update for clientId", clientId, " which uses URI ", metadataUri); + uris.add(metadataUri); + } while (cursor.moveToNext()); + } finally { + cursor.close(); + } + boolean started = false; + for (final String metadataUri : uris) { + if (!TextUtils.isEmpty(metadataUri)) { + // If the metadata URI is empty, that means we should never update it at all. + // It should not be possible to come here with a null metadata URI, because + // it should have been rejected at the time of client registration; if there + // is a bug and it happens anyway, doing nothing is the right thing to do. + // For more information, {@see DictionaryProvider#insert(Uri, ContentValues)}. + updateClientsWithMetadataUri(context, metadataUri); + started = true; + } + } + return started; + } + + /** + * Download latest metadata from the server through DownloadManager for all relevant clients + * + * @param context The context for retrieving resources + * @param metadataUri The client to update + */ + private static void updateClientsWithMetadataUri( + final Context context, final String metadataUri) { + Log.i(TAG, "updateClientsWithMetadataUri() : MetadataUri = " + metadataUri); + // Adding a disambiguator to circumvent a bug in older versions of DownloadManager. + // DownloadManager also stupidly cuts the extension to replace with its own that it + // gets from the content-type. We need to circumvent this. + final String disambiguator = "#" + System.currentTimeMillis() + + ApplicationUtils.getVersionName(context) + ".json"; + final Request metadataRequest = new Request(Uri.parse(metadataUri + disambiguator)); + DebugLogUtils.l("Request =", metadataRequest); + + final Resources res = context.getResources(); + metadataRequest.setAllowedNetworkTypes(Request.NETWORK_WIFI | Request.NETWORK_MOBILE); + metadataRequest.setTitle(res.getString(R.string.download_description)); + // Do not show the notification when downloading the metadata. + metadataRequest.setNotificationVisibility(Request.VISIBILITY_HIDDEN); + metadataRequest.setVisibleInDownloadsUi( + res.getBoolean(R.bool.metadata_downloads_visible_in_download_UI)); + + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + if (maybeCancelUpdateAndReturnIfStillRunning(context, metadataUri, manager, + DictionaryService.NO_CANCEL_DOWNLOAD_PERIOD_MILLIS)) { + // We already have a recent download in progress. Don't register a new download. + return; + } + final long downloadId; + synchronized (sSharedIdProtector) { + downloadId = manager.enqueue(metadataRequest); + DebugLogUtils.l("Metadata download requested with id", downloadId); + // If there is still a download in progress, it's been there for a while and + // there is probably something wrong with download manager. It's best to just + // overwrite the id and request it again. If the old one happens to finish + // anyway, we don't know about its ID any more, so the downloadFinished + // method will ignore it. + writeMetadataDownloadId(context, metadataUri, downloadId); + } + Log.i(TAG, "updateClientsWithMetadataUri() : DownloadId = " + downloadId); + } + + /** + * Cancels downloading a file if there is one for this URI and it's too long. + * + * If we are not currently downloading the file at this URI, this is a no-op. + * + * @param context the context to open the database on + * @param metadataUri the URI to cancel + * @param manager an wrapped instance of DownloadManager + * @param graceTime if there was a download started less than this many milliseconds, don't + * cancel and return true + * @return whether the download is still active + */ + private static boolean maybeCancelUpdateAndReturnIfStillRunning(final Context context, + final String metadataUri, final DownloadManagerWrapper manager, final long graceTime) { + synchronized (sSharedIdProtector) { + final DownloadIdAndStartDate metadataDownloadIdAndStartDate = + MetadataDbHelper.getMetadataDownloadIdAndStartDateForURI(context, metadataUri); + if (null == metadataDownloadIdAndStartDate) return false; + if (NOT_AN_ID == metadataDownloadIdAndStartDate.mId) return false; + if (metadataDownloadIdAndStartDate.mStartDate + graceTime + > System.currentTimeMillis()) { + return true; + } + manager.remove(metadataDownloadIdAndStartDate.mId); + writeMetadataDownloadId(context, metadataUri, NOT_AN_ID); + } + // Consider a cancellation as a failure. As such, inform listeners that the download + // has failed. + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.downloadedMetadata(false); + } + return false; + } + + /** + * Cancels a pending update for this client, if there is one. + * + * If we are not currently updating metadata for this client, this is a no-op. This is a helper + * method that gets the download manager service and the metadata URI for this client. + * + * @param context the context, to get an instance of DownloadManager + * @param clientId the ID of the client we want to cancel the update of + */ + public static void cancelUpdate(final Context context, final String clientId) { + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + final String metadataUri = MetadataDbHelper.getMetadataUriAsString(context, clientId); + maybeCancelUpdateAndReturnIfStillRunning(context, metadataUri, manager, 0 /* graceTime */); + } + + /** + * Registers a download request and flags it as downloading in the metadata table. + * + * This is a helper method that exists to avoid race conditions where DownloadManager might + * finish downloading the file before the data is committed to the database. + * It registers the request with the DownloadManager service and also updates the metadata + * database directly within a synchronized section. + * This method has no intelligence about the data it commits to the database aside from the + * download request id, which is not known before submitting the request to the download + * manager. Hence, it only updates the relevant line. + * + * @param manager a wrapped download manager service to register the request with. + * @param request the request to register. + * @param db the metadata database. + * @param id the id of the word list. + * @param version the version of the word list. + * @return the download id returned by the download manager. + */ + public static long registerDownloadRequest(final DownloadManagerWrapper manager, + final Request request, final SQLiteDatabase db, final String id, final int version) { + Log.i(TAG, "registerDownloadRequest() : Id = " + id + " : Version = " + version); + final long downloadId; + synchronized (sSharedIdProtector) { + downloadId = manager.enqueue(request); + Log.i(TAG, "registerDownloadRequest() : DownloadId = " + downloadId); + MetadataDbHelper.markEntryAsDownloading(db, id, version, downloadId); + } + return downloadId; + } + + /** + * Retrieve information about a specific download from DownloadManager. + */ + private static CompletedDownloadInfo getCompletedDownloadInfo( + final DownloadManagerWrapper manager, final long downloadId) { + final Query query = new Query().setFilterById(downloadId); + final Cursor cursor = manager.query(query); + + if (null == cursor) { + return new CompletedDownloadInfo(null, downloadId, DownloadManager.STATUS_FAILED); + } + try { + final String uri; + final int status; + if (cursor.moveToNext()) { + final int columnStatus = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS); + final int columnError = cursor.getColumnIndex(DownloadManager.COLUMN_REASON); + final int columnUri = cursor.getColumnIndex(DownloadManager.COLUMN_URI); + final int error = cursor.getInt(columnError); + status = cursor.getInt(columnStatus); + final String uriWithAnchor = cursor.getString(columnUri); + int anchorIndex = uriWithAnchor.indexOf('#'); + if (anchorIndex != -1) { + uri = uriWithAnchor.substring(0, anchorIndex); + } else { + uri = uriWithAnchor; + } + if (DownloadManager.STATUS_SUCCESSFUL != status) { + Log.e(TAG, "Permanent failure of download " + downloadId + + " with error code: " + error); + } + } else { + uri = null; + status = DownloadManager.STATUS_FAILED; + } + return new CompletedDownloadInfo(uri, downloadId, status); + } finally { + cursor.close(); + } + } + + private static ArrayList<DownloadRecord> getDownloadRecordsForCompletedDownloadInfo( + final Context context, final CompletedDownloadInfo downloadInfo) { + // Get and check the ID of the file we are waiting for, compare them to downloaded ones + synchronized(sSharedIdProtector) { + final ArrayList<DownloadRecord> downloadRecords = + MetadataDbHelper.getDownloadRecordsForDownloadId(context, + downloadInfo.mDownloadId); + // If any of these is metadata, we should update the DB + boolean hasMetadata = false; + for (DownloadRecord record : downloadRecords) { + if (record.isMetadata()) { + hasMetadata = true; + break; + } + } + if (hasMetadata) { + writeMetadataDownloadId(context, downloadInfo.mUri, NOT_AN_ID); + MetadataDbHelper.saveLastUpdateTimeOfUri(context, downloadInfo.mUri); + } + return downloadRecords; + } + } + + /** + * Take appropriate action after a download finished, in success or in error. + * + * This is called by the system upon broadcast from the DownloadManager that a file + * has been downloaded successfully. + * After a simple check that this is actually the file we are waiting for, this + * method basically coordinates the parsing and comparison of metadata, and fires + * the computation of the list of actions that should be taken then executes them. + * + * @param context The context for this action. + * @param intent The intent from the DownloadManager containing details about the download. + */ + /* package */ static void downloadFinished(final Context context, final Intent intent) { + // Get and check the ID of the file that was downloaded + final long fileId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, NOT_AN_ID); + Log.i(TAG, "downloadFinished() : DownloadId = " + fileId); + if (NOT_AN_ID == fileId) return; // Spurious wake-up: ignore + + final DownloadManagerWrapper manager = new DownloadManagerWrapper(context); + final CompletedDownloadInfo downloadInfo = getCompletedDownloadInfo(manager, fileId); + + final ArrayList<DownloadRecord> recordList = + getDownloadRecordsForCompletedDownloadInfo(context, downloadInfo); + if (null == recordList) return; // It was someone else's download. + DebugLogUtils.l("Received result for download ", fileId); + + // TODO: handle gracefully a null pointer here. This is practically impossible because + // we come here only when DownloadManager explicitly called us when it ended a + // download, so we are pretty sure it's alive. It's theoretically possible that it's + // disabled right inbetween the firing of the intent and the control reaching here. + + for (final DownloadRecord record : recordList) { + // downloadSuccessful is not final because we may still have exceptions from now on + boolean downloadSuccessful = false; + try { + if (downloadInfo.wasSuccessful()) { + downloadSuccessful = handleDownloadedFile(context, record, manager, fileId); + Log.i(TAG, "downloadFinished() : Success = " + downloadSuccessful); + } + } finally { + final String resultMessage = downloadSuccessful ? "Success" : "Failure"; + if (record.isMetadata()) { + Log.i(TAG, "downloadFinished() : Metadata " + resultMessage); + publishUpdateMetadataCompleted(context, downloadSuccessful); + } else { + Log.i(TAG, "downloadFinished() : WordList " + resultMessage); + final SQLiteDatabase db = MetadataDbHelper.getDb(context, record.mClientId); + publishUpdateWordListCompleted(context, downloadSuccessful, fileId, + db, record.mAttributes, record.mClientId); + } + } + } + // Now that we're done using it, we can remove this download from DLManager + manager.remove(fileId); + } + + /** + * Sends a broadcast informing listeners that the dictionaries were updated. + * + * This will call all local listeners through the UpdateEventListener#downloadedMetadata + * callback (for example, the dictionary provider interface uses this to stop the Loading + * animation) and send a broadcast about the metadata having been updated. For a client of + * the dictionary pack like Latin IME, this means it should re-query the dictionary pack + * for any relevant new data. + * + * @param context the context, to send the broadcast. + * @param downloadSuccessful whether the download of the metadata was successful or not. + */ + public static void publishUpdateMetadataCompleted(final Context context, + final boolean downloadSuccessful) { + // We need to warn all listeners of what happened. But some listeners may want to + // remove themselves or re-register something in response. Hence we should take a + // snapshot of the listener list and warn them all. This also prevents any + // concurrent modification problem of the static list. + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.downloadedMetadata(downloadSuccessful); + } + publishUpdateCycleCompletedEvent(context); + } + + private static void publishUpdateWordListCompleted(final Context context, + final boolean downloadSuccessful, final long fileId, + final SQLiteDatabase db, final ContentValues downloadedFileRecord, + final String clientId) { + synchronized(sSharedIdProtector) { + if (downloadSuccessful) { + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.InstallAfterDownloadAction(clientId, + downloadedFileRecord)); + actions.execute(context, new LogProblemReporter(TAG)); + } else { + MetadataDbHelper.deleteDownloadingEntry(db, fileId); + } + } + // See comment above about #linkedCopyOfLists + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.wordListDownloadFinished(downloadedFileRecord.getAsString( + MetadataDbHelper.WORDLISTID_COLUMN), downloadSuccessful); + } + publishUpdateCycleCompletedEvent(context); + } + + private static void publishUpdateCycleCompletedEvent(final Context context) { + // Even if this is not successful, we have to publish the new state. + PrivateLog.log("Publishing update cycle completed event"); + DebugLogUtils.l("Publishing update cycle completed event"); + for (UpdateEventListener listener : linkedCopyOfList(sUpdateEventListeners)) { + listener.updateCycleCompleted(); + } + signalNewDictionaryState(context); + } + + private static boolean handleDownloadedFile(final Context context, + final DownloadRecord downloadRecord, final DownloadManagerWrapper manager, + final long fileId) { + try { + // {@link handleWordList(Context,InputStream,ContentValues)}. + // Handle the downloaded file according to its type + if (downloadRecord.isMetadata()) { + DebugLogUtils.l("Data D/L'd is metadata for", downloadRecord.mClientId); + // #handleMetadata() closes its InputStream argument + handleMetadata(context, new ParcelFileDescriptor.AutoCloseInputStream( + manager.openDownloadedFile(fileId)), downloadRecord.mClientId); + } else { + DebugLogUtils.l("Data D/L'd is a word list"); + final int wordListStatus = downloadRecord.mAttributes.getAsInteger( + MetadataDbHelper.STATUS_COLUMN); + if (MetadataDbHelper.STATUS_DOWNLOADING == wordListStatus) { + // #handleWordList() closes its InputStream argument + handleWordList(context, new ParcelFileDescriptor.AutoCloseInputStream( + manager.openDownloadedFile(fileId)), downloadRecord); + } else { + Log.e(TAG, "Spurious download ended. Maybe a cancelled download?"); + } + } + return true; + } catch (FileNotFoundException e) { + Log.e(TAG, "A file was downloaded but it can't be opened", e); + } catch (IOException e) { + // Can't read the file... disk damage? + Log.e(TAG, "Can't read a file", e); + // TODO: Check with UX how we should warn the user. + } catch (IllegalStateException e) { + // The format of the downloaded file is incorrect. We should maybe report upstream? + Log.e(TAG, "Incorrect data received", e); + } catch (BadFormatException e) { + // The format of the downloaded file is incorrect. We should maybe report upstream? + Log.e(TAG, "Incorrect data received", e); + } + return false; + } + + /** + * Returns a copy of the specified list, with all elements copied. + * + * This returns a linked list. + */ + private static <T> List<T> linkedCopyOfList(final List<T> src) { + // Instantiation of a parameterized type is not possible in Java, so it's not possible to + // return the same type of list that was passed - probably the same reason why Collections + // does not do it. So we need to decide statically which concrete type to return. + return new LinkedList<>(src); + } + + /** + * Warn Kelar Keyboard that the state of dictionaries changed and it should refresh its data. + */ + private static void signalNewDictionaryState(final Context context) { + // TODO: Also provide the locale of the updated dictionary so that the LatinIme + // does not have to reset if it is a different locale. + final Intent newDictBroadcast = + new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + context.sendBroadcast(newDictBroadcast); + } + + /** + * Parse metadata and take appropriate action (that is, upgrade dictionaries). + * @param context the context to read settings. + * @param stream an input stream pointing to the downloaded data. May not be null. + * Will be closed upon finishing. + * @param clientId the ID of the client to update + * @throws BadFormatException if the metadata is not in a known format. + * @throws IOException if the downloaded file can't be read from the disk + */ + public static void handleMetadata(final Context context, final InputStream stream, + final String clientId) throws IOException, BadFormatException { + DebugLogUtils.l("Entering handleMetadata"); + final List<WordListMetadata> newMetadata; + final InputStreamReader reader = new InputStreamReader(stream); + try { + // According to the doc InputStreamReader buffers, so no need to add a buffering layer + newMetadata = MetadataHandler.readMetadata(reader); + } finally { + reader.close(); + } + + DebugLogUtils.l("Downloaded metadata :", newMetadata); + PrivateLog.log("Downloaded metadata\n" + newMetadata); + + final ActionBatch actions = computeUpgradeTo(context, clientId, newMetadata); + // TODO: Check with UX how we should report to the user + // TODO: add an action to close the database + actions.execute(context, new LogProblemReporter(TAG)); + } + + /** + * Handle a word list: put it in its right place, and update the passed content values. + * @param context the context for opening files. + * @param inputStream an input stream pointing to the downloaded data. May not be null. + * Will be closed upon finishing. + * @param downloadRecord the content values to fill the file name in. + * @throws IOException if files can't be read or written. + * @throws BadFormatException if the md5 checksum doesn't match the metadata. + */ + private static void handleWordList(final Context context, + final InputStream inputStream, final DownloadRecord downloadRecord) + throws IOException, BadFormatException { + + // DownloadManager does not have the ability to put the file directly where we want + // it, so we had it download to a temporary place. Now we move it. It will be deleted + // automatically by DownloadManager. + DebugLogUtils.l("Downloaded a new word list :", downloadRecord.mAttributes.getAsString( + MetadataDbHelper.DESCRIPTION_COLUMN), "for", downloadRecord.mClientId); + PrivateLog.log("Downloaded a new word list with description : " + + downloadRecord.mAttributes.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN) + + " for " + downloadRecord.mClientId); + + final String locale = + downloadRecord.mAttributes.getAsString(MetadataDbHelper.LOCALE_COLUMN); + final String destinationFile = getTempFileName(context, locale); + downloadRecord.mAttributes.put(MetadataDbHelper.LOCAL_FILENAME_COLUMN, destinationFile); + + FileOutputStream outputStream = null; + try { + outputStream = context.openFileOutput(destinationFile, Context.MODE_PRIVATE); + copyFile(inputStream, outputStream); + } finally { + inputStream.close(); + if (outputStream != null) { + outputStream.close(); + } + } + + // TODO: Consolidate this MD5 calculation with file copying above. + // We need to reopen the file because the inputstream bytes have been consumed, and there + // is nothing in InputStream to reopen or rewind the stream + FileInputStream copiedFile = null; + final String md5sum; + try { + copiedFile = context.openFileInput(destinationFile); + md5sum = MD5Calculator.checksum(copiedFile); + } finally { + if (copiedFile != null) { + copiedFile.close(); + } + } + if (TextUtils.isEmpty(md5sum)) { + return; // We can't compute the checksum anyway, so return and hope for the best + } + if (!md5sum.equals(downloadRecord.mAttributes.getAsString( + MetadataDbHelper.CHECKSUM_COLUMN))) { + context.deleteFile(destinationFile); + throw new BadFormatException("MD5 checksum check failed : \"" + md5sum + "\" <> \"" + + downloadRecord.mAttributes.getAsString(MetadataDbHelper.CHECKSUM_COLUMN) + + "\""); + } + } + + /** + * Copies in to out using FileChannels. + * + * This tries to use channels for fast copying. If it doesn't work, fall back to + * copyFileFallBack below. + * + * @param in the stream to copy from. + * @param out the stream to copy to. + * @throws IOException if both the normal and fallback methods raise exceptions. + */ + private static void copyFile(final InputStream in, final OutputStream out) + throws IOException { + DebugLogUtils.l("Copying files"); + if (!(in instanceof FileInputStream) || !(out instanceof FileOutputStream)) { + DebugLogUtils.l("Not the right types"); + copyFileFallback(in, out); + } else { + try { + final FileChannel sourceChannel = ((FileInputStream) in).getChannel(); + final FileChannel destinationChannel = ((FileOutputStream) out).getChannel(); + sourceChannel.transferTo(0, Integer.MAX_VALUE, destinationChannel); + } catch (IOException e) { + // Can't work with channels, or something went wrong. Copy by hand. + DebugLogUtils.l("Won't work"); + copyFileFallback(in, out); + } + } + } + + /** + * Copies in to out with read/write methods, not FileChannels. + * + * @param in the stream to copy from. + * @param out the stream to copy to. + * @throws IOException if a read or a write fails. + */ + private static void copyFileFallback(final InputStream in, final OutputStream out) + throws IOException { + DebugLogUtils.l("Falling back to slow copy"); + final byte[] buffer = new byte[FILE_COPY_BUFFER_SIZE]; + for (int readBytes = in.read(buffer); readBytes >= 0; readBytes = in.read(buffer)) + out.write(buffer, 0, readBytes); + } + + /** + * Creates and returns a new file to store a dictionary + * @param context the context to use to open the file. + * @param locale the locale for this dictionary, to make the file name more readable. + * @return the file name, or throw an exception. + * @throws IOException if the file cannot be created. + */ + private static String getTempFileName(final Context context, final String locale) + throws IOException { + DebugLogUtils.l("Entering openTempFileOutput"); + final File dir = context.getFilesDir(); + final File f = File.createTempFile(locale + TEMP_DICT_FILE_SUB, DICT_FILE_SUFFIX, dir); + DebugLogUtils.l("File name is", f.getName()); + return f.getName(); + } + + /** + * Compare metadata (collections of word lists). + * + * This method takes whole metadata sets directly and compares them, matching the wordlists in + * each of them on the id. It creates an ActionBatch object that can be .execute()'d to perform + * the actual upgrade from `from' to `to'. + * + * @param context the context to open databases on. + * @param clientId the id of the client. + * @param from the dictionary descriptor (as a list of wordlists) to upgrade from. + * @param to the dictionary descriptor (as a list of wordlists) to upgrade to. + * @return an ordered list of runnables to be called to upgrade. + */ + private static ActionBatch compareMetadataForUpgrade(final Context context, + final String clientId, @Nullable final List<WordListMetadata> from, + @Nullable final List<WordListMetadata> to) { + final ActionBatch actions = new ActionBatch(); + // Upgrade existing word lists + DebugLogUtils.l("Comparing dictionaries"); + final Set<String> wordListIds = new TreeSet<>(); + // TODO: Can these be null? + final List<WordListMetadata> fromList = (from == null) ? new ArrayList<WordListMetadata>() + : from; + final List<WordListMetadata> toList = (to == null) ? new ArrayList<WordListMetadata>() + : to; + for (WordListMetadata wlData : fromList) wordListIds.add(wlData.mId); + for (WordListMetadata wlData : toList) wordListIds.add(wlData.mId); + for (String id : wordListIds) { + final WordListMetadata currentInfo = MetadataHandler.findWordListById(fromList, id); + final WordListMetadata metadataInfo = MetadataHandler.findWordListById(toList, id); + // TODO: Remove the following unnecessary check, since we are now doing the filtering + // inside findWordListById. + final WordListMetadata newInfo = null == metadataInfo + || metadataInfo.mFormatVersion > MAXIMUM_SUPPORTED_FORMAT_VERSION + ? null : metadataInfo; + DebugLogUtils.l("Considering updating ", id, "currentInfo =", currentInfo); + + if (null == currentInfo && null == newInfo) { + // This may happen if a new word list appeared that we can't handle. + if (null == metadataInfo) { + // What happened? Bug in Set<>? + Log.e(TAG, "Got an id for a wordlist that is neither in from nor in to"); + } else { + // We may come here if there is a new word list that we can't handle. + Log.i(TAG, "Can't handle word list with id '" + id + "' because it has format" + + " version " + metadataInfo.mFormatVersion + " and the maximum version" + + " we can handle is " + MAXIMUM_SUPPORTED_FORMAT_VERSION); + } + continue; + } else if (null == currentInfo) { + // This is the case where a new list that we did not know of popped on the server. + // Make it available. + actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo)); + } else if (null == newInfo) { + // This is the case where an old list we had is not in the server data any more. + // Pass false to ForgetAction: this may be installed and we still want to apply + // a forget-like action (remove the URL) if it is, so we want to turn off the + // status == AVAILABLE check. If it's DELETING, this is the right thing to do, + // as we want to leave the record as long as Kelar Keyboard has not deleted it ; + // the record will be removed when the file is actually deleted. + actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, false)); + } else { + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + if (newInfo.mVersion == currentInfo.mVersion) { + if (TextUtils.equals(newInfo.mRemoteFilename, currentInfo.mRemoteFilename)) { + // If the dictionary url hasn't changed, we should preserve the retryCount. + newInfo.mRetryCount = currentInfo.mRetryCount; + } + // If it's the same id/version, we update the DB with the new values. + // It doesn't matter too much if they didn't change. + actions.add(new ActionBatch.UpdateDataAction(clientId, newInfo)); + } else if (newInfo.mVersion > currentInfo.mVersion) { + // If it's a new version, it's a different entry in the database. Make it + // available, and if it's installed, also start the download. + final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db, + currentInfo.mId, currentInfo.mVersion); + final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN); + actions.add(new ActionBatch.MakeAvailableAction(clientId, newInfo)); + if (status == MetadataDbHelper.STATUS_INSTALLED + || status == MetadataDbHelper.STATUS_DISABLED) { + actions.add(new ActionBatch.StartDownloadAction(clientId, newInfo)); + } else { + // Pass true to ForgetAction: this is indeed an update to a non-installed + // word list, so activate status == AVAILABLE check + // In case the status is DELETING, this is the right thing to do. It will + // leave the entry as DELETING and remove its URL so that Kelar Keyboard + // can delete it the next time it starts up. + actions.add(new ActionBatch.ForgetAction(clientId, currentInfo, true)); + } + } else if (DEBUG) { + Log.i(TAG, "Not updating word list " + id + + " : current list timestamp is " + currentInfo.mLastUpdate + + " ; new list timestamp is " + newInfo.mLastUpdate); + } + } + } + return actions; + } + + /** + * Computes an upgrade from the current state of the dictionaries to some desired state. + * @param context the context for reading settings and files. + * @param clientId the id of the client. + * @param newMetadata the state we want to upgrade to. + * @return the upgrade from the current state to the desired state, ready to be executed. + */ + public static ActionBatch computeUpgradeTo(final Context context, final String clientId, + final List<WordListMetadata> newMetadata) { + final List<WordListMetadata> currentMetadata = + MetadataHandler.getCurrentMetadata(context, clientId); + return compareMetadataForUpgrade(context, clientId, currentMetadata, newMetadata); + } + + /** + * Installs a word list if it has never been requested. + * + * This is called when a word list is requested, and is available but not installed. It checks + * the conditions for auto-installation: if the dictionary is a main dictionary for this + * language, and it has never been opted out through the dictionary interface, then we start + * installing it. For the user who enables a language and uses it for the first time, the + * dictionary should magically start being used a short time after they start typing. + * The mayPrompt argument indicates whether we should prompt the user for a decision to + * download or not, in case we decide we are in the case where we should download - this + * roughly happens when the current connectivity is 3G. See + * DictionaryProvider#getDictionaryWordListsForContentUri for details. + */ + // As opposed to many other methods, this method does not need the version of the word + // list because it may only install the latest version we know about for this specific + // word list ID / client ID combination. + public static void installIfNeverRequested(final Context context, final String clientId, + final String wordlistId) { + Log.i(TAG, "installIfNeverRequested() : ClientId = " + clientId + + " : WordListId = " + wordlistId); + final String[] idArray = wordlistId.split(DictionaryProvider.ID_CATEGORY_SEPARATOR); + // If we have a new-format dictionary id (category:manual_id), then use the + // specified category. Otherwise, it is a main dictionary, so force the + // MAIN category upon it. + final String category = 2 == idArray.length ? idArray[0] : MAIN_DICTIONARY_CATEGORY; + if (!MAIN_DICTIONARY_CATEGORY.equals(category)) { + // Not a main dictionary. We only auto-install main dictionaries, so we can return now. + return; + } + if (CommonPreferences.getCommonPreferences(context).contains(wordlistId)) { + // If some kind of settings has been done in the past for this specific id, then + // this is not a candidate for auto-install. Because it already is either true, + // in which case it may be installed or downloading or whatever, and we don't + // need to care about it because it's already handled or being handled, or it's false + // in which case it means the user explicitely turned it off and don't want to have + // it installed. So we quit right away. + return; + } + + final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId); + final ContentValues installCandidate = + MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId); + if (MetadataDbHelper.STATUS_AVAILABLE + != installCandidate.getAsInteger(MetadataDbHelper.STATUS_COLUMN)) { + // If it's not "AVAILABLE", we want to stop now. Because candidates for auto-install + // are lists that we know are available, but we also know have never been installed. + // It does obviously not concern already installed lists, or downloading lists, + // or those that have been disabled, flagged as deleting... So anything else than + // AVAILABLE means we don't auto-install. + return; + } + + // We decided against prompting the user for a decision. This may be because we were + // explicitly asked not to, or because we are currently on wi-fi anyway, or because we + // already know the answer to the question. We'll enqueue a request ; StartDownloadAction + // knows to use the correct type of network according to the current settings. + + // Also note that once it's auto-installed, a word list will be marked as INSTALLED. It will + // thus receive automatic updates if there are any, which is what we want. If the user does + // not want this word list, they will have to go to the settings and change them, which will + // change the shared preferences. So there is no way for a word list that has been + // auto-installed once to get auto-installed again, and that's what we want. + final ActionBatch actions = new ActionBatch(); + WordListMetadata metadata = WordListMetadata.createFromContentValues(installCandidate); + actions.add(new ActionBatch.StartDownloadAction(clientId, metadata)); + final String localeString = installCandidate.getAsString(MetadataDbHelper.LOCALE_COLUMN); + + // We are in a content provider: we can't do any UI at all. We have to defer the displaying + // itself to the service. Also, we only display this when the user does not have a + // dictionary for this language already. During setup wizard, however, this UI is + // suppressed. + final boolean deviceProvisioned = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.DEVICE_PROVISIONED, 0) != 0; + if (deviceProvisioned) { + final Intent intent = new Intent(); + intent.setClass(context, DictionaryService.class); + intent.setAction(DictionaryService.SHOW_DOWNLOAD_TOAST_INTENT_ACTION); + intent.putExtra(DictionaryService.LOCALE_INTENT_ARGUMENT, localeString); + context.startService(intent); + } else { + Log.i(TAG, "installIfNeverRequested() : Don't show download toast"); + } + + Log.i(TAG, "installIfNeverRequested() : StartDownloadAction for " + metadata); + actions.execute(context, new LogProblemReporter(TAG)); + } + + /** + * Marks the word list with the passed id as used. + * + * This will download/install the list as required. The action will see that the destination + * word list is a valid list, and take appropriate action - in this case, mark it as used. + * @see ActionBatch.Action#execute + * + * @param context the context for using action batches. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as installed. + * @param version the version of the word list to mark as installed. + * @param status the current status of the word list. + * @param allowDownloadOnMeteredData whether to download even on metered data connection + */ + // The version argument is not used yet, because we don't need it to retrieve the information + // we need. However, the pair (id, version) being the primary key to a word list in the database + // it feels better for consistency to pass it, and some methods retrieving information about a + // word list need it so we may need it in the future. + public static void markAsUsed(final Context context, final String clientId, + final String wordlistId, final int version, + final int status, final boolean allowDownloadOnMeteredData) { + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + + final ActionBatch actions = new ActionBatch(); + if (MetadataDbHelper.STATUS_DISABLED == status + || MetadataDbHelper.STATUS_DELETING == status) { + actions.add(new ActionBatch.EnableAction(clientId, wordListMetaData)); + } else if (MetadataDbHelper.STATUS_AVAILABLE == status) { + actions.add(new ActionBatch.StartDownloadAction(clientId, wordListMetaData)); + } else { + Log.e(TAG, "Unexpected state of the word list for markAsUsed : " + status); + } + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as unused. + * + * This leaves the file on the disk for ulterior use. The action will see that the destination + * word list is null, and take appropriate action - in this case, mark it as unused. + * @see ActionBatch.Action#execute + * + * @param context the context for using action batches. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as installed. + * @param version the version of the word list to mark as installed. + * @param status the current status of the word list. + */ + // The version and status arguments are not used yet, but this method matches its interface to + // markAsUsed for consistency. + public static void markAsUnused(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.DisableAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as deleting. + * + * This basically means that on the next chance there is (right away if Kelar Keyboard + * happens to be up, or the next time it gets up otherwise) the dictionary pack will + * supply an empty dictionary to it that will replace whatever dictionary is installed. + * This allows to release the space taken by a dictionary (except for the few bytes the + * empty dictionary takes up), and override a built-in default dictionary so that we + * can fake delete a built-in dictionary. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as deleted. + * @param version the version of the word list to mark as deleted. + * @param status the current status of the word list. + */ + public static void markAsDeleting(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.DisableAction(clientId, wordListMetaData)); + actions.add(new ActionBatch.StartDeleteAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Marks the word list with the passed id as actually deleted. + * + * This reverts to available status or deletes the row as appropriate. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list to mark as deleted. + * @param version the version of the word list to mark as deleted. + * @param status the current status of the word list. + */ + public static void markAsDeleted(final Context context, final String clientId, + final String wordlistId, final int version, final int status) { + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + + if (null == wordListMetaData) return; + + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.FinishDeleteAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + signalNewDictionaryState(context); + } + + /** + * Checks whether the word list should be downloaded again; in which case an download & + * installation attempt is made. Otherwise the word list is marked broken. + * + * @param context the context to open the database on. + * @param clientId the id of the client. + * @param wordlistId the id of the word list which is broken. + * @param version the version of the broken word list. + */ + public static void markAsBrokenOrRetrying(final Context context, final String clientId, + final String wordlistId, final int version) { + boolean isRetryPossible = MetadataDbHelper.maybeMarkEntryAsRetrying( + MetadataDbHelper.getDb(context, clientId), wordlistId, version); + + if (isRetryPossible) { + if (DEBUG) { + Log.d(TAG, "Attempting to download & install the wordlist again."); + } + final WordListMetadata wordListMetaData = MetadataHandler.getCurrentMetadataForWordList( + context, clientId, wordlistId, version); + if (wordListMetaData == null) { + return; + } + + final ActionBatch actions = new ActionBatch(); + actions.add(new ActionBatch.StartDownloadAction(clientId, wordListMetaData)); + actions.execute(context, new LogProblemReporter(TAG)); + } else { + if (DEBUG) { + Log.d(TAG, "Retries for wordlist exhausted, deleting the wordlist from table."); + } + MetadataDbHelper.deleteEntry(MetadataDbHelper.getDb(context, clientId), + wordlistId, version); + } + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java b/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java new file mode 100644 index 000000000..276077a80 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.ContentValues; + +import javax.annotation.Nonnull; + +/** + * The metadata for a single word list. + * + * Instances of this class are always immutable. + */ +public class WordListMetadata { + + public final String mId; + public final int mType; // Type, as of MetadataDbHelper#TYPE_* + public final String mDescription; + public final long mLastUpdate; + public final long mFileSize; + public final String mRawChecksum; + public final String mChecksum; + public final String mLocalFilename; + public final String mRemoteFilename; + public final int mVersion; // version of this word list + public final int mFlags; // Always 0 in this version, reserved for future use + public int mRetryCount; + + // The locale is matched against the locale requested by the client. The matching algorithm + // is a standard locale matching with fallback; it is implemented in + // DictionaryProvider#getDictionaryFileForContentUri. + public final String mLocale; + + + // Version number of the format. + // This implementation of the DictionaryDataService knows how to handle format 1 only. + // This is only for forward compatibility, to be able to upgrade the format without + // breaking old implementations. + public final int mFormatVersion; + + public WordListMetadata(final String id, final int type, + final String description, final long lastUpdate, final long fileSize, + final String rawChecksum, final String checksum, final int retryCount, + final String localFilename, final String remoteFilename, + final int version, final int formatVersion, + final int flags, final String locale) { + mId = id; + mType = type; + mDescription = description; + mLastUpdate = lastUpdate; // In milliseconds + mFileSize = fileSize; + mRawChecksum = rawChecksum; + mChecksum = checksum; + mRetryCount = retryCount; + mLocalFilename = localFilename; + mRemoteFilename = remoteFilename; + mVersion = version; + mFormatVersion = formatVersion; + mFlags = flags; + mLocale = locale; + } + + /** + * Create a WordListMetadata from the contents of a ContentValues. + * + * If this lacks any required field, IllegalArgumentException is thrown. + */ + public static WordListMetadata createFromContentValues(@Nonnull final ContentValues values) { + final String id = values.getAsString(MetadataDbHelper.WORDLISTID_COLUMN); + final Integer type = values.getAsInteger(MetadataDbHelper.TYPE_COLUMN); + final String description = values.getAsString(MetadataDbHelper.DESCRIPTION_COLUMN); + final Long lastUpdate = values.getAsLong(MetadataDbHelper.DATE_COLUMN); + final Long fileSize = values.getAsLong(MetadataDbHelper.FILESIZE_COLUMN); + final String rawChecksum = values.getAsString(MetadataDbHelper.RAW_CHECKSUM_COLUMN); + final String checksum = values.getAsString(MetadataDbHelper.CHECKSUM_COLUMN); + final int retryCount = values.getAsInteger(MetadataDbHelper.RETRY_COUNT_COLUMN); + final String localFilename = values.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN); + final String remoteFilename = values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN); + final Integer version = values.getAsInteger(MetadataDbHelper.VERSION_COLUMN); + final Integer formatVersion = values.getAsInteger(MetadataDbHelper.FORMATVERSION_COLUMN); + final Integer flags = values.getAsInteger(MetadataDbHelper.FLAGS_COLUMN); + final String locale = values.getAsString(MetadataDbHelper.LOCALE_COLUMN); + if (null == id + || null == type + || null == description + || null == lastUpdate + || null == fileSize + || null == checksum + || null == localFilename + || null == remoteFilename + || null == version + || null == formatVersion + || null == flags + || null == locale) { + throw new IllegalArgumentException(); + } + return new WordListMetadata(id, type, description, lastUpdate, fileSize, rawChecksum, + checksum, retryCount, localFilename, remoteFilename, version, formatVersion, + flags, locale); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(WordListMetadata.class.getSimpleName()); + sb.append(" : ").append(mId); + sb.append("\nType : ").append(mType); + sb.append("\nDescription : ").append(mDescription); + sb.append("\nLastUpdate : ").append(mLastUpdate); + sb.append("\nFileSize : ").append(mFileSize); + sb.append("\nRawChecksum : ").append(mRawChecksum); + sb.append("\nChecksum : ").append(mChecksum); + sb.append("\nRetryCount: ").append(mRetryCount); + sb.append("\nLocalFilename : ").append(mLocalFilename); + sb.append("\nRemoteFilename : ").append(mRemoteFilename); + sb.append("\nVersion : ").append(mVersion); + sb.append("\nFormatVersion : ").append(mFormatVersion); + sb.append("\nFlags : ").append(mFlags); + sb.append("\nLocale : ").append(mLocale); + return sb.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java b/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java new file mode 100644 index 000000000..8c8a3fd99 --- /dev/null +++ b/java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java @@ -0,0 +1,310 @@ +/** + * Copyright (C) 2011 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 org.kelar.inputmethod.dictionarypack; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.ListView; +import android.widget.TextView; + +import org.kelar.inputmethod.latin.R; + +import java.util.Locale; + +/** + * A preference for one word list. + * + * This preference refers to a single word list, as available in the dictionary + * pack. Upon being pressed, it displays a menu to allow the user to install, disable, + * enable or delete it as appropriate for the current state of the word list. + */ +public final class WordListPreference extends Preference { + private static final String TAG = WordListPreference.class.getSimpleName(); + + // What to display in the "status" field when we receive unknown data as a status from + // the content provider. Empty string sounds sensible. + private static final String NO_STATUS_MESSAGE = ""; + + /// Actions + private static final int ACTION_UNKNOWN = 0; + private static final int ACTION_ENABLE_DICT = 1; + private static final int ACTION_DISABLE_DICT = 2; + private static final int ACTION_DELETE_DICT = 3; + + // Members + // The metadata word list id and version of this word list. + public final String mWordlistId; + public final int mVersion; + public final Locale mLocale; + public final String mDescription; + + // The id of the client for which this preference is. + private final String mClientId; + // The status + private int mStatus; + // The size of the dictionary file + private final int mFilesize; + + private final DictionaryListInterfaceState mInterfaceState; + + public WordListPreference(final Context context, + final DictionaryListInterfaceState dictionaryListInterfaceState, final String clientId, + final String wordlistId, final int version, final Locale locale, + final String description, final int status, final int filesize) { + super(context, null); + mInterfaceState = dictionaryListInterfaceState; + mClientId = clientId; + mVersion = version; + mWordlistId = wordlistId; + mFilesize = filesize; + mLocale = locale; + mDescription = description; + + setLayoutResource(R.layout.dictionary_line); + + setTitle(description); + setStatus(status); + setKey(wordlistId); + } + + public void setStatus(final int status) { + if (status == mStatus) return; + mStatus = status; + setSummary(getSummary(status)); + } + + public boolean hasStatus(final int status) { + return status == mStatus; + } + + @Override + public View onCreateView(final ViewGroup parent) { + final View orphanedView = mInterfaceState.findFirstOrphanedView(); + if (null != orphanedView) return orphanedView; // Will be sent to onBindView + final View newView = super.onCreateView(parent); + return mInterfaceState.addToCacheAndReturnView(newView); + } + + public boolean hasPriorityOver(final int otherPrefStatus) { + // Both of these should be one of MetadataDbHelper.STATUS_* + return mStatus > otherPrefStatus; + } + + private String getSummary(final int status) { + final Context context = getContext(); + switch (status) { + // If we are deleting the word list, for the user it's like it's already deleted. + // It should be reinstallable. Exposing to the user the whole complexity of + // the delayed deletion process between the dictionary pack and Kelar Keyboard + // would only be confusing. + case MetadataDbHelper.STATUS_DELETING: + case MetadataDbHelper.STATUS_AVAILABLE: + return context.getString(R.string.dictionary_available); + case MetadataDbHelper.STATUS_DOWNLOADING: + return context.getString(R.string.dictionary_downloading); + case MetadataDbHelper.STATUS_INSTALLED: + return context.getString(R.string.dictionary_installed); + case MetadataDbHelper.STATUS_DISABLED: + return context.getString(R.string.dictionary_disabled); + default: + return NO_STATUS_MESSAGE; + } + } + + // The table below needs to be kept in sync with MetadataDbHelper.STATUS_* since it uses + // the values as indices. + private static final int sStatusActionList[][] = { + // MetadataDbHelper.STATUS_UNKNOWN + {}, + // MetadataDbHelper.STATUS_AVAILABLE + { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT }, + // MetadataDbHelper.STATUS_DOWNLOADING + { ButtonSwitcher.STATUS_CANCEL, ACTION_DISABLE_DICT }, + // MetadataDbHelper.STATUS_INSTALLED + { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, + // MetadataDbHelper.STATUS_DISABLED + { ButtonSwitcher.STATUS_DELETE, ACTION_DELETE_DICT }, + // MetadataDbHelper.STATUS_DELETING + // We show 'install' because the file is supposed to be deleted. + // The user may reinstall it. + { ButtonSwitcher.STATUS_INSTALL, ACTION_ENABLE_DICT } + }; + + static int getButtonSwitcherStatus(final int status) { + if (status >= sStatusActionList.length) { + Log.e(TAG, "Unknown status " + status); + return ButtonSwitcher.STATUS_NO_BUTTON; + } + return sStatusActionList[status][0]; + } + + static int getActionIdFromStatusAndMenuEntry(final int status) { + if (status >= sStatusActionList.length) { + Log.e(TAG, "Unknown status " + status); + return ACTION_UNKNOWN; + } + return sStatusActionList[status][1]; + } + + private void disableDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.disable(prefs, mWordlistId); + UpdateHandler.markAsUnused(context, mClientId, mWordlistId, mVersion, mStatus); + if (MetadataDbHelper.STATUS_DOWNLOADING == mStatus) { + setStatus(MetadataDbHelper.STATUS_AVAILABLE); + } else if (MetadataDbHelper.STATUS_INSTALLED == mStatus) { + // Interface-wise, we should no longer be able to come here. However, this is still + // the right thing to do if we do come here. + setStatus(MetadataDbHelper.STATUS_DISABLED); + } else { + Log.e(TAG, "Unexpected state of the word list for disabling " + mStatus); + } + } + + private void enableDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.enable(prefs, mWordlistId); + // Explicit enabling by the user : allow downloading on metered data connection. + UpdateHandler.markAsUsed(context, mClientId, mWordlistId, mVersion, mStatus, true); + if (MetadataDbHelper.STATUS_AVAILABLE == mStatus) { + setStatus(MetadataDbHelper.STATUS_DOWNLOADING); + } else if (MetadataDbHelper.STATUS_DISABLED == mStatus + || MetadataDbHelper.STATUS_DELETING == mStatus) { + // If the status is DELETING, it means Kelar Keyboard + // has not deleted the word list yet, so we can safely + // turn it to 'installed'. The status DISABLED is still supported internally to + // avoid breaking older installations and all but there should not be a way to + // disable a word list through the interface any more. + setStatus(MetadataDbHelper.STATUS_INSTALLED); + } else { + Log.e(TAG, "Unexpected state of the word list for enabling " + mStatus); + } + } + + private void deleteDict() { + final Context context = getContext(); + final SharedPreferences prefs = CommonPreferences.getCommonPreferences(context); + CommonPreferences.disable(prefs, mWordlistId); + setStatus(MetadataDbHelper.STATUS_DELETING); + UpdateHandler.markAsDeleting(context, mClientId, mWordlistId, mVersion, mStatus); + } + + @Override + protected void onBindView(final View view) { + super.onBindView(view); + ((ViewGroup)view).setLayoutTransition(null); + + final DictionaryDownloadProgressBar progressBar = + (DictionaryDownloadProgressBar)view.findViewById(R.id.dictionary_line_progress_bar); + final TextView status = (TextView)view.findViewById(android.R.id.summary); + progressBar.setIds(mClientId, mWordlistId); + progressBar.setMax(mFilesize); + final boolean showProgressBar = (MetadataDbHelper.STATUS_DOWNLOADING == mStatus); + setSummary(getSummary(mStatus)); + status.setVisibility(showProgressBar ? View.INVISIBLE : View.VISIBLE); + progressBar.setVisibility(showProgressBar ? View.VISIBLE : View.INVISIBLE); + + final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)view.findViewById( + R.id.wordlist_button_switcher); + // We need to clear the state of the button switcher, because we reuse views; if we didn't + // reset it would animate from whatever its old state was. + buttonSwitcher.reset(mInterfaceState); + if (mInterfaceState.isOpen(mWordlistId)) { + // The button is open. + final int previousStatus = mInterfaceState.getStatus(mWordlistId); + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(previousStatus)); + if (previousStatus != mStatus) { + // We come here if the status has changed since last time. We need to animate + // the transition. + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); + mInterfaceState.setOpen(mWordlistId, mStatus); + } + } else { + // The button is closed. + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); + } + buttonSwitcher.setInternalOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onActionButtonClicked(); + } + }); + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onWordListClicked(v); + } + }); + } + + void onWordListClicked(final View v) { + // Note : v is the preference view + final ViewParent parent = v.getParent(); + // Just in case something changed in the framework, test for the concrete class + if (!(parent instanceof ListView)) return; + final ListView listView = (ListView)parent; + final int indexToOpen; + // Close all first, we'll open back any item that needs to be open. + final boolean wasOpen = mInterfaceState.isOpen(mWordlistId); + mInterfaceState.closeAll(); + if (wasOpen) { + // This button being shown. Take note that we don't want to open any button in the + // loop below. + indexToOpen = -1; + } else { + // This button was not being shown. Open it, and remember the index of this + // child as the one to open in the following loop. + mInterfaceState.setOpen(mWordlistId, mStatus); + indexToOpen = listView.indexOfChild(v); + } + final int lastDisplayedIndex = + listView.getLastVisiblePosition() - listView.getFirstVisiblePosition(); + // The "lastDisplayedIndex" is actually displayed, hence the <= + for (int i = 0; i <= lastDisplayedIndex; ++i) { + final ButtonSwitcher buttonSwitcher = (ButtonSwitcher)listView.getChildAt(i) + .findViewById(R.id.wordlist_button_switcher); + if (i == indexToOpen) { + buttonSwitcher.setStatusAndUpdateVisuals(getButtonSwitcherStatus(mStatus)); + } else { + buttonSwitcher.setStatusAndUpdateVisuals(ButtonSwitcher.STATUS_NO_BUTTON); + } + } + } + + void onActionButtonClicked() { + switch (getActionIdFromStatusAndMenuEntry(mStatus)) { + case ACTION_ENABLE_DICT: + enableDict(); + break; + case ACTION_DISABLE_DICT: + disableDict(); + break; + case ACTION_DELETE_DICT: + deleteDict(); + break; + default: + Log.e(TAG, "Unknown menu item pressed"); + } + } +} diff --git a/java/src/org/kelar/inputmethod/event/Combiner.java b/java/src/org/kelar/inputmethod/event/Combiner.java new file mode 100644 index 000000000..45945d460 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/Combiner.java @@ -0,0 +1,51 @@ +/* + * 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 org.kelar.inputmethod.event; + +import java.util.ArrayList; + +import javax.annotation.Nonnull; + +/** + * A generic interface for combiners. Combiners are objects that transform chains of input events + * into committable strings and manage feedback to show to the user on the combining state. + */ +public interface Combiner { + /** + * Process an event, possibly combining it with the existing state and return the new event. + * + * If this event does not result in any new event getting passed down the chain, this method + * returns null. It may also modify the previous event list if appropriate. + * + * @param previousEvents the previous events in this composition. + * @param event the event to combine with the existing state. + * @return the resulting event. + */ + @Nonnull + Event processEvent(ArrayList<Event> previousEvents, Event event); + + /** + * Get the feedback that should be shown to the user for the current state of this combiner. + * @return A CharSequence representing the feedback to show users. It may include styles. + */ + CharSequence getCombiningStateFeedback(); + + /** + * Reset the state of this combiner, for example when the cursor was moved. + */ + void reset(); +} diff --git a/java/src/org/kelar/inputmethod/event/CombinerChain.java b/java/src/org/kelar/inputmethod/event/CombinerChain.java new file mode 100644 index 000000000..afd992e62 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/CombinerChain.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.event; + +import android.text.SpannableStringBuilder; +import android.text.TextUtils; + +import org.kelar.inputmethod.latin.common.Constants; + +import java.util.ArrayList; + +import javax.annotation.Nonnull; + +/** + * This class implements the logic chain between receiving events and generating code points. + * + * Event sources are multiple. It may be a hardware keyboard, a D-PAD, a software keyboard, + * or any exotic input source. + * This class will orchestrate the composing chain that starts with an event as its input. Each + * composer will be given turns one after the other. + * The output is composed of two sequences of code points: the first, representing the already + * finished combining part, will be shown normally as the composing string, while the second is + * feedback on the composing state and will typically be shown with different styling such as + * a colored background. + */ +public class CombinerChain { + // The already combined text, as described above + private StringBuilder mCombinedText; + // The feedback on the composing state, as described above + private SpannableStringBuilder mStateFeedback; + private final ArrayList<Combiner> mCombiners; + + /** + * Create an combiner chain. + * + * The combiner chain takes events as inputs and outputs code points and combining state. + * For example, if the input language is Japanese, the combining chain will typically perform + * kana conversion. This takes a string for initial text, taken to be present before the + * cursor: we'll start after this. + * + * @param initialText The text that has already been combined so far. + */ + public CombinerChain(final String initialText) { + mCombiners = new ArrayList<>(); + // The dead key combiner is always active, and always first + mCombiners.add(new DeadKeyCombiner()); + mCombinedText = new StringBuilder(initialText); + mStateFeedback = new SpannableStringBuilder(); + } + + public void reset() { + mCombinedText.setLength(0); + mStateFeedback.clear(); + for (final Combiner c : mCombiners) { + c.reset(); + } + } + + private void updateStateFeedback() { + mStateFeedback.clear(); + for (int i = mCombiners.size() - 1; i >= 0; --i) { + mStateFeedback.append(mCombiners.get(i).getCombiningStateFeedback()); + } + } + + /** + * Process an event through the combining chain, and return a processed event to apply. + * @param previousEvents the list of previous events in this composition + * @param newEvent the new event to process + * @return the processed event. It may be the same event, or a consumed event, or a completely + * new event. However it may never be null. + */ + @Nonnull + public Event processEvent(final ArrayList<Event> previousEvents, + @Nonnull final Event newEvent) { + final ArrayList<Event> modifiablePreviousEvents = new ArrayList<>(previousEvents); + Event event = newEvent; + for (final Combiner combiner : mCombiners) { + // A combiner can never return more than one event; it can return several + // code points, but they should be encapsulated within one event. + event = combiner.processEvent(modifiablePreviousEvents, event); + if (event.isConsumed()) { + // If the event is consumed, then we don't pass it to subsequent combiners: + // they should not see it at all. + break; + } + } + updateStateFeedback(); + return event; + } + + /** + * Apply a processed event. + * @param event the event to be applied + */ + public void applyProcessedEvent(final Event event) { + if (null != event) { + // TODO: figure out the generic way of doing this + if (Constants.CODE_DELETE == event.mKeyCode) { + final int length = mCombinedText.length(); + if (length > 0) { + final int lastCodePoint = mCombinedText.codePointBefore(length); + mCombinedText.delete(length - Character.charCount(lastCodePoint), length); + } + } else { + final CharSequence textToCommit = event.getTextToCommit(); + if (!TextUtils.isEmpty(textToCommit)) { + mCombinedText.append(textToCommit); + } + } + } + updateStateFeedback(); + } + + /** + * Get the char sequence that should be displayed as the composing word. It may include + * styling spans. + */ + public CharSequence getComposingWordWithCombiningFeedback() { + final SpannableStringBuilder s = new SpannableStringBuilder(mCombinedText); + return s.append(mStateFeedback); + } +} diff --git a/java/src/org/kelar/inputmethod/event/DeadKeyCombiner.java b/java/src/org/kelar/inputmethod/event/DeadKeyCombiner.java new file mode 100644 index 000000000..562fc2761 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/DeadKeyCombiner.java @@ -0,0 +1,303 @@ +/* + * 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 org.kelar.inputmethod.event; + +import android.text.TextUtils; +import android.util.SparseIntArray; + +import org.kelar.inputmethod.latin.common.Constants; + +import java.text.Normalizer; +import java.util.ArrayList; + +import javax.annotation.Nonnull; + +/** + * A combiner that handles dead keys. + */ +public class DeadKeyCombiner implements Combiner { + + private static class Data { + // This class data taken from KeyCharacterMap.java. + + /* Characters used to display placeholders for dead keys. */ + private static final int ACCENT_ACUTE = '\u00B4'; + private static final int ACCENT_BREVE = '\u02D8'; + private static final int ACCENT_CARON = '\u02C7'; + private static final int ACCENT_CEDILLA = '\u00B8'; + private static final int ACCENT_CIRCUMFLEX = '\u02C6'; + private static final int ACCENT_COMMA_ABOVE = '\u1FBD'; + private static final int ACCENT_COMMA_ABOVE_RIGHT = '\u02BC'; + private static final int ACCENT_DOT_ABOVE = '\u02D9'; + private static final int ACCENT_DOT_BELOW = Constants.CODE_PERIOD; // approximate + private static final int ACCENT_DOUBLE_ACUTE = '\u02DD'; + private static final int ACCENT_GRAVE = '\u02CB'; + private static final int ACCENT_HOOK_ABOVE = '\u02C0'; + private static final int ACCENT_HORN = Constants.CODE_SINGLE_QUOTE; // approximate + private static final int ACCENT_MACRON = '\u00AF'; + private static final int ACCENT_MACRON_BELOW = '\u02CD'; + private static final int ACCENT_OGONEK = '\u02DB'; + private static final int ACCENT_REVERSED_COMMA_ABOVE = '\u02BD'; + private static final int ACCENT_RING_ABOVE = '\u02DA'; + private static final int ACCENT_STROKE = Constants.CODE_DASH; // approximate + private static final int ACCENT_TILDE = '\u02DC'; + private static final int ACCENT_TURNED_COMMA_ABOVE = '\u02BB'; + private static final int ACCENT_UMLAUT = '\u00A8'; + private static final int ACCENT_VERTICAL_LINE_ABOVE = '\u02C8'; + private static final int ACCENT_VERTICAL_LINE_BELOW = '\u02CC'; + + /* Legacy dead key display characters used in previous versions of the API (before L) + * We still support these characters by mapping them to their non-legacy version. */ + private static final int ACCENT_GRAVE_LEGACY = Constants.CODE_GRAVE_ACCENT; + private static final int ACCENT_CIRCUMFLEX_LEGACY = Constants.CODE_CIRCUMFLEX_ACCENT; + private static final int ACCENT_TILDE_LEGACY = Constants.CODE_TILDE; + + /** + * Maps Unicode combining diacritical to display-form dead key. + */ + static final SparseIntArray sCombiningToAccent = new SparseIntArray(); + static final SparseIntArray sAccentToCombining = new SparseIntArray(); + static { + // U+0300: COMBINING GRAVE ACCENT + addCombining('\u0300', ACCENT_GRAVE); + // U+0301: COMBINING ACUTE ACCENT + addCombining('\u0301', ACCENT_ACUTE); + // U+0302: COMBINING CIRCUMFLEX ACCENT + addCombining('\u0302', ACCENT_CIRCUMFLEX); + // U+0303: COMBINING TILDE + addCombining('\u0303', ACCENT_TILDE); + // U+0304: COMBINING MACRON + addCombining('\u0304', ACCENT_MACRON); + // U+0306: COMBINING BREVE + addCombining('\u0306', ACCENT_BREVE); + // U+0307: COMBINING DOT ABOVE + addCombining('\u0307', ACCENT_DOT_ABOVE); + // U+0308: COMBINING DIAERESIS + addCombining('\u0308', ACCENT_UMLAUT); + // U+0309: COMBINING HOOK ABOVE + addCombining('\u0309', ACCENT_HOOK_ABOVE); + // U+030A: COMBINING RING ABOVE + addCombining('\u030A', ACCENT_RING_ABOVE); + // U+030B: COMBINING DOUBLE ACUTE ACCENT + addCombining('\u030B', ACCENT_DOUBLE_ACUTE); + // U+030C: COMBINING CARON + addCombining('\u030C', ACCENT_CARON); + // U+030D: COMBINING VERTICAL LINE ABOVE + addCombining('\u030D', ACCENT_VERTICAL_LINE_ABOVE); + // U+030E: COMBINING DOUBLE VERTICAL LINE ABOVE + //addCombining('\u030E', ACCENT_DOUBLE_VERTICAL_LINE_ABOVE); + // U+030F: COMBINING DOUBLE GRAVE ACCENT + //addCombining('\u030F', ACCENT_DOUBLE_GRAVE); + // U+0310: COMBINING CANDRABINDU + //addCombining('\u0310', ACCENT_CANDRABINDU); + // U+0311: COMBINING INVERTED BREVE + //addCombining('\u0311', ACCENT_INVERTED_BREVE); + // U+0312: COMBINING TURNED COMMA ABOVE + addCombining('\u0312', ACCENT_TURNED_COMMA_ABOVE); + // U+0313: COMBINING COMMA ABOVE + addCombining('\u0313', ACCENT_COMMA_ABOVE); + // U+0314: COMBINING REVERSED COMMA ABOVE + addCombining('\u0314', ACCENT_REVERSED_COMMA_ABOVE); + // U+0315: COMBINING COMMA ABOVE RIGHT + addCombining('\u0315', ACCENT_COMMA_ABOVE_RIGHT); + // U+031B: COMBINING HORN + addCombining('\u031B', ACCENT_HORN); + // U+0323: COMBINING DOT BELOW + addCombining('\u0323', ACCENT_DOT_BELOW); + // U+0326: COMBINING COMMA BELOW + //addCombining('\u0326', ACCENT_COMMA_BELOW); + // U+0327: COMBINING CEDILLA + addCombining('\u0327', ACCENT_CEDILLA); + // U+0328: COMBINING OGONEK + addCombining('\u0328', ACCENT_OGONEK); + // U+0329: COMBINING VERTICAL LINE BELOW + addCombining('\u0329', ACCENT_VERTICAL_LINE_BELOW); + // U+0331: COMBINING MACRON BELOW + addCombining('\u0331', ACCENT_MACRON_BELOW); + // U+0335: COMBINING SHORT STROKE OVERLAY + addCombining('\u0335', ACCENT_STROKE); + // U+0342: COMBINING GREEK PERISPOMENI + //addCombining('\u0342', ACCENT_PERISPOMENI); + // U+0344: COMBINING GREEK DIALYTIKA TONOS + //addCombining('\u0344', ACCENT_DIALYTIKA_TONOS); + // U+0345: COMBINING GREEK YPOGEGRAMMENI + //addCombining('\u0345', ACCENT_YPOGEGRAMMENI); + + // One-way mappings to equivalent preferred accents. + // U+0340: COMBINING GRAVE TONE MARK + sCombiningToAccent.append('\u0340', ACCENT_GRAVE); + // U+0341: COMBINING ACUTE TONE MARK + sCombiningToAccent.append('\u0341', ACCENT_ACUTE); + // U+0343: COMBINING GREEK KORONIS + sCombiningToAccent.append('\u0343', ACCENT_COMMA_ABOVE); + + // One-way legacy mappings to preserve compatibility with older applications. + // U+0300: COMBINING GRAVE ACCENT + sAccentToCombining.append(ACCENT_GRAVE_LEGACY, '\u0300'); + // U+0302: COMBINING CIRCUMFLEX ACCENT + sAccentToCombining.append(ACCENT_CIRCUMFLEX_LEGACY, '\u0302'); + // U+0303: COMBINING TILDE + sAccentToCombining.append(ACCENT_TILDE_LEGACY, '\u0303'); + } + + private static void addCombining(int combining, int accent) { + sCombiningToAccent.append(combining, accent); + sAccentToCombining.append(accent, combining); + } + + // Caution! This may only contain chars, not supplementary code points. It's unlikely + // it will ever need to, but if it does we'll have to change this + private static final SparseIntArray sNonstandardDeadCombinations = new SparseIntArray(); + static { + // Non-standard decompositions. + // Stroke modifier for Finnish multilingual keyboard and others. + // U+0110: LATIN CAPITAL LETTER D WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'D', '\u0110'); + // U+01E4: LATIN CAPITAL LETTER G WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'G', '\u01e4'); + // U+0126: LATIN CAPITAL LETTER H WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'H', '\u0126'); + // U+0197: LATIN CAPITAL LETTER I WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'I', '\u0197'); + // U+0141: LATIN CAPITAL LETTER L WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'L', '\u0141'); + // U+00D8: LATIN CAPITAL LETTER O WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'O', '\u00d8'); + // U+0166: LATIN CAPITAL LETTER T WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'T', '\u0166'); + // U+0111: LATIN SMALL LETTER D WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'd', '\u0111'); + // U+01E5: LATIN SMALL LETTER G WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'g', '\u01e5'); + // U+0127: LATIN SMALL LETTER H WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'h', '\u0127'); + // U+0268: LATIN SMALL LETTER I WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'i', '\u0268'); + // U+0142: LATIN SMALL LETTER L WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'l', '\u0142'); + // U+00F8: LATIN SMALL LETTER O WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 'o', '\u00f8'); + // U+0167: LATIN SMALL LETTER T WITH STROKE + addNonStandardDeadCombination(ACCENT_STROKE, 't', '\u0167'); + } + + private static void addNonStandardDeadCombination(final int deadCodePoint, + final int spacingCodePoint, final int result) { + final int combination = (deadCodePoint << 16) | spacingCodePoint; + sNonstandardDeadCombinations.put(combination, result); + } + + public static final int NOT_A_CHAR = 0; + public static final int BITS_TO_SHIFT_DEAD_CODE_POINT_FOR_NON_STANDARD_COMBINATION = 16; + // Get a non-standard combination + public static char getNonstandardCombination(final int deadCodePoint, + final int spacingCodePoint) { + final int combination = spacingCodePoint | + (deadCodePoint << BITS_TO_SHIFT_DEAD_CODE_POINT_FOR_NON_STANDARD_COMBINATION); + return (char)sNonstandardDeadCombinations.get(combination, NOT_A_CHAR); + } + } + + // TODO: make this a list of events instead + final StringBuilder mDeadSequence = new StringBuilder(); + + @Nonnull + private static Event createEventChainFromSequence(final @Nonnull CharSequence text, + @Nonnull final Event originalEvent) { + int index = text.length(); + if (index <= 0) { + return originalEvent; + } + Event lastEvent = null; + do { + final int codePoint = Character.codePointBefore(text, index); + lastEvent = Event.createHardwareKeypressEvent(codePoint, + originalEvent.mKeyCode, lastEvent, false /* isKeyRepeat */); + index -= Character.charCount(codePoint); + } while (index > 0); + return lastEvent; + } + + @Override + @Nonnull + public Event processEvent(final ArrayList<Event> previousEvents, final Event event) { + if (TextUtils.isEmpty(mDeadSequence)) { + // No dead char is currently being tracked: this is the most common case. + if (event.isDead()) { + // The event was a dead key. Start tracking it. + mDeadSequence.appendCodePoint(event.mCodePoint); + return Event.createConsumedEvent(event); + } + // Regular keystroke when not keeping track of a dead key. Simply said, there are + // no dead keys at all in the current input, so this combiner has nothing to do and + // simply returns the event as is. The majority of events will go through this path. + return event; + } + if (Character.isWhitespace(event.mCodePoint) + || event.mCodePoint == mDeadSequence.codePointBefore(mDeadSequence.length())) { + // When whitespace or twice the same dead key, we should output the dead sequence as is. + final Event resultEvent = createEventChainFromSequence(mDeadSequence.toString(), + event); + mDeadSequence.setLength(0); + return resultEvent; + } + if (event.isFunctionalKeyEvent()) { + if (Constants.CODE_DELETE == event.mKeyCode) { + // Remove the last code point + final int trimIndex = mDeadSequence.length() - Character.charCount( + mDeadSequence.codePointBefore(mDeadSequence.length())); + mDeadSequence.setLength(trimIndex); + return Event.createConsumedEvent(event); + } + return event; + } + if (event.isDead()) { + mDeadSequence.appendCodePoint(event.mCodePoint); + return Event.createConsumedEvent(event); + } + // Combine normally. + final StringBuilder sb = new StringBuilder(); + sb.appendCodePoint(event.mCodePoint); + int codePointIndex = 0; + while (codePointIndex < mDeadSequence.length()) { + final int deadCodePoint = mDeadSequence.codePointAt(codePointIndex); + final char replacementSpacingChar = + Data.getNonstandardCombination(deadCodePoint, event.mCodePoint); + if (Data.NOT_A_CHAR != replacementSpacingChar) { + sb.setCharAt(0, replacementSpacingChar); + } else { + final int combining = Data.sAccentToCombining.get(deadCodePoint); + sb.appendCodePoint(0 == combining ? deadCodePoint : combining); + } + codePointIndex += Character.isSupplementaryCodePoint(deadCodePoint) ? 2 : 1; + } + final String normalizedString = Normalizer.normalize(sb, Normalizer.Form.NFC); + final Event resultEvent = createEventChainFromSequence(normalizedString, event); + mDeadSequence.setLength(0); + return resultEvent; + } + + @Override + public void reset() { + mDeadSequence.setLength(0); + } + + @Override + public CharSequence getCombiningStateFeedback() { + return mDeadSequence; + } +} diff --git a/java/src/org/kelar/inputmethod/event/Event.java b/java/src/org/kelar/inputmethod/event/Event.java new file mode 100644 index 000000000..17c9717c5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/Event.java @@ -0,0 +1,319 @@ +/* + * 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 org.kelar.inputmethod.event; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import javax.annotation.Nonnull; + +/** + * Class representing a generic input event as handled by Latin IME. + * + * This contains information about the origin of the event, but it is generalized and should + * represent a software keypress, hardware keypress, or d-pad move alike. + * Very importantly, this does not necessarily result in inputting one character, or even anything + * at all - it may be a dead key, it may be a partial input, it may be a special key on the + * keyboard, it may be a cancellation of a keypress (e.g. in a soft keyboard the finger of the + * user has slid out of the key), etc. It may also be a batch input from a gesture or handwriting + * for example. + * The combiner should figure out what to do with this. + */ +public class Event { + // Should the types below be represented by separate classes instead? It would be cleaner + // but probably a bit too much + // An event we don't handle in Latin IME, for example pressing Ctrl on a hardware keyboard. + final public static int EVENT_TYPE_NOT_HANDLED = 0; + // A key press that is part of input, for example pressing an alphabetic character on a + // hardware qwerty keyboard. It may be part of a sequence that will be re-interpreted later + // through combination. + final public static int EVENT_TYPE_INPUT_KEYPRESS = 1; + // A toggle event is triggered by a key that affects the previous character. An example would + // be a numeric key on a 10-key keyboard, which would toggle between 1 - a - b - c with + // repeated presses. + final public static int EVENT_TYPE_TOGGLE = 2; + // A mode event instructs the combiner to change modes. The canonical example would be the + // hankaku/zenkaku key on a Japanese keyboard, or even the caps lock key on a qwerty keyboard + // if handled at the combiner level. + final public static int EVENT_TYPE_MODE_KEY = 3; + // An event corresponding to a gesture. + final public static int EVENT_TYPE_GESTURE = 4; + // An event corresponding to the manual pick of a suggestion. + final public static int EVENT_TYPE_SUGGESTION_PICKED = 5; + // An event corresponding to a string generated by some software process. + final public static int EVENT_TYPE_SOFTWARE_GENERATED_STRING = 6; + // An event corresponding to a cursor move + final public static int EVENT_TYPE_CURSOR_MOVE = 7; + + // 0 is a valid code point, so we use -1 here. + final public static int NOT_A_CODE_POINT = -1; + // -1 is a valid key code, so we use 0 here. + final public static int NOT_A_KEY_CODE = 0; + + final private static int FLAG_NONE = 0; + // This event is a dead character, usually input by a dead key. Examples include dead-acute + // or dead-abovering. + final private static int FLAG_DEAD = 0x1; + // This event is coming from a key repeat, software or hardware. + final private static int FLAG_REPEAT = 0x2; + // This event has already been consumed. + final private static int FLAG_CONSUMED = 0x4; + + final private int mEventType; // The type of event - one of the constants above + // The code point associated with the event, if relevant. This is a unicode code point, and + // has nothing to do with other representations of the key. It is only relevant if this event + // is of KEYPRESS type, but for a mode key like hankaku/zenkaku or ctrl, there is no code point + // associated so this should be NOT_A_CODE_POINT to avoid unintentional use of its value when + // it's not relevant. + final public int mCodePoint; + + // If applicable, this contains the string that should be input. + final public CharSequence mText; + + // The key code associated with the event, if relevant. This is relevant whenever this event + // has been triggered by a key press, but not for a gesture for example. This has conceptually + // no link to the code point, although keys that enter a straight code point may often set + // this to be equal to mCodePoint for convenience. If this is not a key, this must contain + // NOT_A_KEY_CODE. + final public int mKeyCode; + + // Coordinates of the touch event, if relevant. If useful, we may want to replace this with + // a MotionEvent or something in the future. This is only relevant when the keypress is from + // a software keyboard obviously, unless there are touch-sensitive hardware keyboards in the + // future or some other awesome sauce. + final public int mX; + final public int mY; + + // Some flags that can't go into the key code. It's a bit field of FLAG_* + final private int mFlags; + + // If this is of type EVENT_TYPE_SUGGESTION_PICKED, this must not be null (and must be null in + // other cases). + final public SuggestedWordInfo mSuggestedWordInfo; + + // The next event, if any. Null if there is no next event yet. + final public Event mNextEvent; + + // This method is private - to create a new event, use one of the create* utility methods. + private Event(final int type, final CharSequence text, final int codePoint, final int keyCode, + final int x, final int y, final SuggestedWordInfo suggestedWordInfo, final int flags, + final Event next) { + mEventType = type; + mText = text; + mCodePoint = codePoint; + mKeyCode = keyCode; + mX = x; + mY = y; + mSuggestedWordInfo = suggestedWordInfo; + mFlags = flags; + mNextEvent = next; + // Validity checks + // mSuggestedWordInfo is non-null if and only if the type is SUGGESTION_PICKED + if (EVENT_TYPE_SUGGESTION_PICKED == mEventType) { + if (null == mSuggestedWordInfo) { + throw new RuntimeException("Wrong event: SUGGESTION_PICKED event must have a " + + "non-null SuggestedWordInfo"); + } + } else { + if (null != mSuggestedWordInfo) { + throw new RuntimeException("Wrong event: only SUGGESTION_PICKED events may have " + + "a non-null SuggestedWordInfo"); + } + } + } + + @Nonnull + public static Event createSoftwareKeypressEvent(final int codePoint, final int keyCode, + final int x, final int y, final boolean isKeyRepeat) { + return new Event(EVENT_TYPE_INPUT_KEYPRESS, null /* text */, codePoint, keyCode, x, y, + null /* suggestedWordInfo */, isKeyRepeat ? FLAG_REPEAT : FLAG_NONE, null); + } + + @Nonnull + public static Event createHardwareKeypressEvent(final int codePoint, final int keyCode, + final Event next, final boolean isKeyRepeat) { + return new Event(EVENT_TYPE_INPUT_KEYPRESS, null /* text */, codePoint, keyCode, + Constants.EXTERNAL_KEYBOARD_COORDINATE, Constants.EXTERNAL_KEYBOARD_COORDINATE, + null /* suggestedWordInfo */, isKeyRepeat ? FLAG_REPEAT : FLAG_NONE, next); + } + + // This creates an input event for a dead character. @see {@link #FLAG_DEAD} + @ExternallyReferenced + @Nonnull + public static Event createDeadEvent(final int codePoint, final int keyCode, final Event next) { + // TODO: add an argument or something if we ever create a software layout with dead keys. + return new Event(EVENT_TYPE_INPUT_KEYPRESS, null /* text */, codePoint, keyCode, + Constants.EXTERNAL_KEYBOARD_COORDINATE, Constants.EXTERNAL_KEYBOARD_COORDINATE, + null /* suggestedWordInfo */, FLAG_DEAD, next); + } + + /** + * Create an input event with nothing but a code point. This is the most basic possible input + * event; it contains no information on many things the IME requires to function correctly, + * so avoid using it unless really nothing is known about this input. + * @param codePoint the code point. + * @return an event for this code point. + */ + @Nonnull + public static Event createEventForCodePointFromUnknownSource(final int codePoint) { + // TODO: should we have a different type of event for this? After all, it's not a key press. + return new Event(EVENT_TYPE_INPUT_KEYPRESS, null /* text */, codePoint, NOT_A_KEY_CODE, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, + null /* suggestedWordInfo */, FLAG_NONE, null /* next */); + } + + /** + * Creates an input event with a code point and x, y coordinates. This is typically used when + * resuming a previously-typed word, when the coordinates are still known. + * @param codePoint the code point to input. + * @param x the X coordinate. + * @param y the Y coordinate. + * @return an event for this code point and coordinates. + */ + @Nonnull + public static Event createEventForCodePointFromAlreadyTypedText(final int codePoint, + final int x, final int y) { + // TODO: should we have a different type of event for this? After all, it's not a key press. + return new Event(EVENT_TYPE_INPUT_KEYPRESS, null /* text */, codePoint, NOT_A_KEY_CODE, + x, y, null /* suggestedWordInfo */, FLAG_NONE, null /* next */); + } + + /** + * Creates an input event representing the manual pick of a suggestion. + * @return an event for this suggestion pick. + */ + @Nonnull + public static Event createSuggestionPickedEvent(final SuggestedWordInfo suggestedWordInfo) { + return new Event(EVENT_TYPE_SUGGESTION_PICKED, suggestedWordInfo.mWord, + NOT_A_CODE_POINT, NOT_A_KEY_CODE, + Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, + suggestedWordInfo, FLAG_NONE, null /* next */); + } + + /** + * Creates an input event with a CharSequence. This is used by some software processes whose + * output is a string, possibly with styling. Examples include press on a multi-character key, + * or combination that outputs a string. + * @param text the CharSequence associated with this event. + * @param keyCode the key code, or NOT_A_KEYCODE if not applicable. + * @return an event for this text. + */ + @Nonnull + public static Event createSoftwareTextEvent(final CharSequence text, final int keyCode) { + return new Event(EVENT_TYPE_SOFTWARE_GENERATED_STRING, text, NOT_A_CODE_POINT, keyCode, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, + null /* suggestedWordInfo */, FLAG_NONE, null /* next */); + } + + /** + * Creates an input event representing the manual pick of a punctuation suggestion. + * @return an event for this suggestion pick. + */ + @Nonnull + public static Event createPunctuationSuggestionPickedEvent( + final SuggestedWordInfo suggestedWordInfo) { + final int primaryCode = suggestedWordInfo.mWord.charAt(0); + return new Event(EVENT_TYPE_SUGGESTION_PICKED, suggestedWordInfo.mWord, primaryCode, + NOT_A_KEY_CODE, Constants.SUGGESTION_STRIP_COORDINATE, + Constants.SUGGESTION_STRIP_COORDINATE, suggestedWordInfo, FLAG_NONE, + null /* next */); + } + + /** + * Creates an input event representing moving the cursor. The relative move amount is stored + * in mX. + * @param moveAmount the relative move amount. + * @return an event for this cursor move. + */ + @Nonnull + public static Event createCursorMovedEvent(final int moveAmount) { + return new Event(EVENT_TYPE_CURSOR_MOVE, null, NOT_A_CODE_POINT, NOT_A_KEY_CODE, + moveAmount, Constants.NOT_A_COORDINATE, null, FLAG_NONE, null); + } + + /** + * Creates an event identical to the passed event, but that has already been consumed. + * @param source the event to copy the properties of. + * @return an identical event marked as consumed. + */ + @Nonnull + public static Event createConsumedEvent(final Event source) { + // A consumed event should not input any text at all, so we pass the empty string as text. + return new Event(source.mEventType, source.mText, source.mCodePoint, source.mKeyCode, + source.mX, source.mY, source.mSuggestedWordInfo, source.mFlags | FLAG_CONSUMED, + source.mNextEvent); + } + + @Nonnull + public static Event createNotHandledEvent() { + return new Event(EVENT_TYPE_NOT_HANDLED, null /* text */, NOT_A_CODE_POINT, NOT_A_KEY_CODE, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, + null /* suggestedWordInfo */, FLAG_NONE, null); + } + + // Returns whether this is a function key like backspace, ctrl, settings... as opposed to keys + // that result in input like letters or space. + public boolean isFunctionalKeyEvent() { + // This logic may need to be refined in the future + return NOT_A_CODE_POINT == mCodePoint; + } + + // Returns whether this event is for a dead character. @see {@link #FLAG_DEAD} + public boolean isDead() { + return 0 != (FLAG_DEAD & mFlags); + } + + public boolean isKeyRepeat() { + return 0 != (FLAG_REPEAT & mFlags); + } + + public boolean isConsumed() { return 0 != (FLAG_CONSUMED & mFlags); } + + public boolean isGesture() { return EVENT_TYPE_GESTURE == mEventType; } + + // Returns whether this is a fake key press from the suggestion strip. This happens with + // punctuation signs selected from the suggestion strip. + public boolean isSuggestionStripPress() { + return EVENT_TYPE_SUGGESTION_PICKED == mEventType; + } + + public boolean isHandled() { + return EVENT_TYPE_NOT_HANDLED != mEventType; + } + + public CharSequence getTextToCommit() { + if (isConsumed()) { + return ""; // A consumed event should input no text. + } + switch (mEventType) { + case EVENT_TYPE_MODE_KEY: + case EVENT_TYPE_NOT_HANDLED: + case EVENT_TYPE_TOGGLE: + case EVENT_TYPE_CURSOR_MOVE: + return ""; + case EVENT_TYPE_INPUT_KEYPRESS: + return StringUtils.newSingleCodePointString(mCodePoint); + case EVENT_TYPE_GESTURE: + case EVENT_TYPE_SOFTWARE_GENERATED_STRING: + case EVENT_TYPE_SUGGESTION_PICKED: + return mText; + } + throw new RuntimeException("Unknown event type: " + mEventType); + } +} diff --git a/java/src/org/kelar/inputmethod/event/EventDecoder.java b/java/src/org/kelar/inputmethod/event/EventDecoder.java new file mode 100644 index 000000000..3826f0608 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/EventDecoder.java @@ -0,0 +1,24 @@ +/* + * 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 org.kelar.inputmethod.event; + +/** + * A generic interface for event decoders. + */ +public interface EventDecoder { + +} diff --git a/java/src/org/kelar/inputmethod/event/HardwareEventDecoder.java b/java/src/org/kelar/inputmethod/event/HardwareEventDecoder.java new file mode 100644 index 000000000..0b75ab8b3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/HardwareEventDecoder.java @@ -0,0 +1,26 @@ +/* + * 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 org.kelar.inputmethod.event; + +import android.view.KeyEvent; + +/** + * An event decoder for hardware events. + */ +public interface HardwareEventDecoder extends EventDecoder { + public Event decodeHardwareKey(final KeyEvent keyEvent); +} diff --git a/java/src/org/kelar/inputmethod/event/HardwareKeyboardEventDecoder.java b/java/src/org/kelar/inputmethod/event/HardwareKeyboardEventDecoder.java new file mode 100644 index 000000000..c8cfbc2cc --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/HardwareKeyboardEventDecoder.java @@ -0,0 +1,81 @@ +/* + * 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 org.kelar.inputmethod.event; + +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +import org.kelar.inputmethod.latin.common.Constants; + +/** + * A hardware event decoder for a hardware qwerty-ish keyboard. + * + * The events are always hardware keypresses, but they can be key down or key up events, they + * can be dead keys, they can be meta keys like shift or ctrl... This does not deal with + * 10-key like keyboards; a different decoder is used for this. + */ +public class HardwareKeyboardEventDecoder implements HardwareEventDecoder { + final int mDeviceId; + + public HardwareKeyboardEventDecoder(final int deviceId) { + mDeviceId = deviceId; + // TODO: get the layout for this hardware keyboard + } + + @Override + public Event decodeHardwareKey(final KeyEvent keyEvent) { + // KeyEvent#getUnicodeChar() does not exactly returns a unicode char, but rather a value + // that includes both the unicode char in the lower 21 bits and flags in the upper bits, + // hence the name "codePointAndFlags". {@see KeyEvent#getUnicodeChar()} for more info. + final int codePointAndFlags = keyEvent.getUnicodeChar(); + // The keyCode is the abstraction used by the KeyEvent to represent different keys that + // do not necessarily map to a unicode character. This represents a physical key, like + // the key for 'A' or Space, but also Backspace or Ctrl or Caps Lock. + final int keyCode = keyEvent.getKeyCode(); + final boolean isKeyRepeat = (0 != keyEvent.getRepeatCount()); + if (KeyEvent.KEYCODE_DEL == keyCode) { + return Event.createHardwareKeypressEvent(Event.NOT_A_CODE_POINT, Constants.CODE_DELETE, + null /* next */, isKeyRepeat); + } + if (keyEvent.isPrintingKey() || KeyEvent.KEYCODE_SPACE == keyCode + || KeyEvent.KEYCODE_ENTER == keyCode) { + if (0 != (codePointAndFlags & KeyCharacterMap.COMBINING_ACCENT)) { + // A dead key. + return Event.createDeadEvent( + codePointAndFlags & KeyCharacterMap.COMBINING_ACCENT_MASK, keyCode, + null /* next */); + } + if (KeyEvent.KEYCODE_ENTER == keyCode) { + // The Enter key. If the Shift key is not being pressed, this should send a + // CODE_ENTER to trigger the action if any, or a carriage return otherwise. If the + // Shift key is being pressed, this should send a CODE_SHIFT_ENTER and let + // Latin IME decide what to do with it. + if (keyEvent.isShiftPressed()) { + return Event.createHardwareKeypressEvent(Event.NOT_A_CODE_POINT, + Constants.CODE_SHIFT_ENTER, null /* next */, isKeyRepeat); + } + return Event.createHardwareKeypressEvent(Constants.CODE_ENTER, keyCode, + null /* next */, isKeyRepeat); + } + // If not Enter, then this is just a regular keypress event for a normal character + // that can be committed right away, taking into account the current state. + return Event.createHardwareKeypressEvent(codePointAndFlags, keyCode, null /* next */, + isKeyRepeat); + } + return Event.createNotHandledEvent(); + } +} diff --git a/java/src/org/kelar/inputmethod/event/InputTransaction.java b/java/src/org/kelar/inputmethod/event/InputTransaction.java new file mode 100644 index 000000000..096ae68f5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/event/InputTransaction.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.event; + +import org.kelar.inputmethod.latin.settings.SettingsValues; + +/** + * An object encapsulating a single transaction for input. + */ +public class InputTransaction { + // UPDATE_LATER is stronger than UPDATE_NOW. The reason for this is, if we have to update later, + // it's because something will change that we can't evaluate now, which means that even if we + // re-evaluate now we'll have to do it again later. The only case where that wouldn't apply + // would be if we needed to update now to find out the new state right away, but then we + // can't do it with this deferred mechanism anyway. + public static final int SHIFT_NO_UPDATE = 0; + public static final int SHIFT_UPDATE_NOW = 1; + public static final int SHIFT_UPDATE_LATER = 2; + + // Initial conditions + public final SettingsValues mSettingsValues; + public final Event mEvent; + public final long mTimestamp; + public final int mSpaceState; + public final int mShiftState; + + // Outputs + private int mRequiredShiftUpdate = SHIFT_NO_UPDATE; + private boolean mRequiresUpdateSuggestions = false; + private boolean mDidAffectContents = false; + private boolean mDidAutoCorrect = false; + + public InputTransaction(final SettingsValues settingsValues, final Event event, + final long timestamp, final int spaceState, final int shiftState) { + mSettingsValues = settingsValues; + mEvent = event; + mTimestamp = timestamp; + mSpaceState = spaceState; + mShiftState = shiftState; + } + + /** + * Indicate that this transaction requires some type of shift update. + * @param updateType What type of shift update this requires. + */ + public void requireShiftUpdate(final int updateType) { + mRequiredShiftUpdate = Math.max(mRequiredShiftUpdate, updateType); + } + + /** + * Gets what type of shift update this transaction requires. + * @return The shift update type. + */ + public int getRequiredShiftUpdate() { + return mRequiredShiftUpdate; + } + + /** + * Indicate that this transaction requires updating the suggestions. + */ + public void setRequiresUpdateSuggestions() { + mRequiresUpdateSuggestions = true; + } + + /** + * Find out whether this transaction requires updating the suggestions. + * @return Whether this transaction requires updating the suggestions. + */ + public boolean requiresUpdateSuggestions() { + return mRequiresUpdateSuggestions; + } + + /** + * Indicate that this transaction affected the contents of the editor. + */ + public void setDidAffectContents() { + mDidAffectContents = true; + } + + /** + * Find out whether this transaction affected contents of the editor. + * @return Whether this transaction affected contents of the editor. + */ + public boolean didAffectContents() { + return mDidAffectContents; + } + + /** + * Indicate that this transaction performed an auto-correction. + */ + public void setDidAutoCorrect() { + mDidAutoCorrect = true; + } + + /** + * Find out whether this transaction performed an auto-correction. + * @return Whether this transaction performed an auto-correction. + */ + public boolean didAutoCorrect() { + return mDidAutoCorrect; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/Key.java b/java/src/org/kelar/inputmethod/keyboard/Key.java new file mode 100644 index 000000000..492cec9df --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/Key.java @@ -0,0 +1,1022 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +import static org.kelar.inputmethod.keyboard.internal.KeyboardIconsSet.ICON_UNDEFINED; +import static org.kelar.inputmethod.latin.common.Constants.CODE_OUTPUT_TEXT; +import static org.kelar.inputmethod.latin.common.Constants.CODE_SHIFT; +import static org.kelar.inputmethod.latin.common.Constants.CODE_SWITCH_ALPHA_SYMBOL; +import static org.kelar.inputmethod.latin.common.Constants.CODE_UNSPECIFIED; + +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; + +import org.kelar.inputmethod.keyboard.internal.KeyDrawParams; +import org.kelar.inputmethod.keyboard.internal.KeySpecParser; +import org.kelar.inputmethod.keyboard.internal.KeyStyle; +import org.kelar.inputmethod.keyboard.internal.KeyVisualAttributes; +import org.kelar.inputmethod.keyboard.internal.KeyboardIconsSet; +import org.kelar.inputmethod.keyboard.internal.KeyboardParams; +import org.kelar.inputmethod.keyboard.internal.KeyboardRow; +import org.kelar.inputmethod.keyboard.internal.MoreKeySpec; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.Arrays; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Class for describing the position and characteristics of a single key in the keyboard. + */ +public class Key implements Comparable<Key> { + /** + * The key code (unicode or custom code) that this key generates. + */ + private final int mCode; + + /** Label to display */ + private final String mLabel; + /** Hint label to display on the key in conjunction with the label */ + private final String mHintLabel; + /** Flags of the label */ + private final int mLabelFlags; + private static final int LABEL_FLAGS_ALIGN_HINT_LABEL_TO_BOTTOM = 0x02; + private static final int LABEL_FLAGS_ALIGN_ICON_TO_BOTTOM = 0x04; + private static final int LABEL_FLAGS_ALIGN_LABEL_OFF_CENTER = 0x08; + // Font typeface specification. + private static final int LABEL_FLAGS_FONT_MASK = 0x30; + private static final int LABEL_FLAGS_FONT_NORMAL = 0x10; + private static final int LABEL_FLAGS_FONT_MONO_SPACE = 0x20; + private static final int LABEL_FLAGS_FONT_DEFAULT = 0x30; + // Start of key text ratio enum values + private static final int LABEL_FLAGS_FOLLOW_KEY_TEXT_RATIO_MASK = 0x1C0; + private static final int LABEL_FLAGS_FOLLOW_KEY_LARGE_LETTER_RATIO = 0x40; + private static final int LABEL_FLAGS_FOLLOW_KEY_LETTER_RATIO = 0x80; + private static final int LABEL_FLAGS_FOLLOW_KEY_LABEL_RATIO = 0xC0; + private static final int LABEL_FLAGS_FOLLOW_KEY_HINT_LABEL_RATIO = 0x140; + // End of key text ratio mask enum values + private static final int LABEL_FLAGS_HAS_POPUP_HINT = 0x200; + private static final int LABEL_FLAGS_HAS_SHIFTED_LETTER_HINT = 0x400; + private static final int LABEL_FLAGS_HAS_HINT_LABEL = 0x800; + // The bit to calculate the ratio of key label width against key width. If autoXScale bit is on + // and autoYScale bit is off, the key label may be shrunk only for X-direction. + // If both autoXScale and autoYScale bits are on, the key label text size may be auto scaled. + private static final int LABEL_FLAGS_AUTO_X_SCALE = 0x4000; + private static final int LABEL_FLAGS_AUTO_Y_SCALE = 0x8000; + private static final int LABEL_FLAGS_AUTO_SCALE = LABEL_FLAGS_AUTO_X_SCALE + | LABEL_FLAGS_AUTO_Y_SCALE; + private static final int LABEL_FLAGS_PRESERVE_CASE = 0x10000; + private static final int LABEL_FLAGS_SHIFTED_LETTER_ACTIVATED = 0x20000; + private static final int LABEL_FLAGS_FROM_CUSTOM_ACTION_LABEL = 0x40000; + private static final int LABEL_FLAGS_FOLLOW_FUNCTIONAL_TEXT_COLOR = 0x80000; + private static final int LABEL_FLAGS_KEEP_BACKGROUND_ASPECT_RATIO = 0x100000; + private static final int LABEL_FLAGS_DISABLE_HINT_LABEL = 0x40000000; + private static final int LABEL_FLAGS_DISABLE_ADDITIONAL_MORE_KEYS = 0x80000000; + + /** Icon to display instead of a label. Icon takes precedence over a label */ + private final int mIconId; + + /** Width of the key, excluding the gap */ + private final int mWidth; + /** Height of the key, excluding the gap */ + private final int mHeight; + /** + * The combined width in pixels of the horizontal gaps belonging to this key, both to the left + * and to the right. I.e., mWidth + mHorizontalGap = total width belonging to the key. + */ + private final int mHorizontalGap; + /** + * The combined height in pixels of the vertical gaps belonging to this key, both above and + * below. I.e., mHeight + mVerticalGap = total height belonging to the key. + */ + private final int mVerticalGap; + /** X coordinate of the top-left corner of the key in the keyboard layout, excluding the gap. */ + private final int mX; + /** Y coordinate of the top-left corner of the key in the keyboard layout, excluding the gap. */ + private final int mY; + /** Hit bounding box of the key */ + @Nonnull + private final Rect mHitBox = new Rect(); + + /** More keys. It is guaranteed that this is null or an array of one or more elements */ + @Nullable + private final MoreKeySpec[] mMoreKeys; + /** More keys column number and flags */ + private final int mMoreKeysColumnAndFlags; + private static final int MORE_KEYS_COLUMN_NUMBER_MASK = 0x000000ff; + // If this flag is specified, more keys keyboard should have the specified number of columns. + // Otherwise more keys keyboard should have less than or equal to the specified maximum number + // of columns. + private static final int MORE_KEYS_FLAGS_FIXED_COLUMN = 0x00000100; + // If this flag is specified, the order of more keys is determined by the order in the more + // keys' specification. Otherwise the order of more keys is automatically determined. + private static final int MORE_KEYS_FLAGS_FIXED_ORDER = 0x00000200; + private static final int MORE_KEYS_MODE_MAX_COLUMN_WITH_AUTO_ORDER = 0; + private static final int MORE_KEYS_MODE_FIXED_COLUMN_WITH_AUTO_ORDER = + MORE_KEYS_FLAGS_FIXED_COLUMN; + private static final int MORE_KEYS_MODE_FIXED_COLUMN_WITH_FIXED_ORDER = + (MORE_KEYS_FLAGS_FIXED_COLUMN | MORE_KEYS_FLAGS_FIXED_ORDER); + private static final int MORE_KEYS_FLAGS_HAS_LABELS = 0x40000000; + private static final int MORE_KEYS_FLAGS_NEEDS_DIVIDERS = 0x20000000; + private static final int MORE_KEYS_FLAGS_NO_PANEL_AUTO_MORE_KEY = 0x10000000; + // TODO: Rename these specifiers to !autoOrder! and !fixedOrder! respectively. + private static final String MORE_KEYS_AUTO_COLUMN_ORDER = "!autoColumnOrder!"; + private static final String MORE_KEYS_FIXED_COLUMN_ORDER = "!fixedColumnOrder!"; + private static final String MORE_KEYS_HAS_LABELS = "!hasLabels!"; + private static final String MORE_KEYS_NEEDS_DIVIDERS = "!needsDividers!"; + private static final String MORE_KEYS_NO_PANEL_AUTO_MORE_KEY = "!noPanelAutoMoreKey!"; + + /** Background type that represents different key background visual than normal one. */ + private final int mBackgroundType; + public static final int BACKGROUND_TYPE_EMPTY = 0; + public static final int BACKGROUND_TYPE_NORMAL = 1; + public static final int BACKGROUND_TYPE_FUNCTIONAL = 2; + public static final int BACKGROUND_TYPE_STICKY_OFF = 3; + public static final int BACKGROUND_TYPE_STICKY_ON = 4; + public static final int BACKGROUND_TYPE_ACTION = 5; + public static final int BACKGROUND_TYPE_SPACEBAR = 6; + + private final int mActionFlags; + private static final int ACTION_FLAGS_IS_REPEATABLE = 0x01; + private static final int ACTION_FLAGS_NO_KEY_PREVIEW = 0x02; + private static final int ACTION_FLAGS_ALT_CODE_WHILE_TYPING = 0x04; + private static final int ACTION_FLAGS_ENABLE_LONG_PRESS = 0x08; + + @Nullable + private final KeyVisualAttributes mKeyVisualAttributes; + @Nullable + private final OptionalAttributes mOptionalAttributes; + + private static final class OptionalAttributes { + /** Text to output when pressed. This can be multiple characters, like ".com" */ + public final String mOutputText; + public final int mAltCode; + /** Icon for disabled state */ + public final int mDisabledIconId; + /** The visual insets */ + public final int mVisualInsetsLeft; + public final int mVisualInsetsRight; + + private OptionalAttributes(final String outputText, final int altCode, + final int disabledIconId, final int visualInsetsLeft, final int visualInsetsRight) { + mOutputText = outputText; + mAltCode = altCode; + mDisabledIconId = disabledIconId; + mVisualInsetsLeft = visualInsetsLeft; + mVisualInsetsRight = visualInsetsRight; + } + + @Nullable + public static OptionalAttributes newInstance(final String outputText, final int altCode, + final int disabledIconId, final int visualInsetsLeft, final int visualInsetsRight) { + if (outputText == null && altCode == CODE_UNSPECIFIED + && disabledIconId == ICON_UNDEFINED && visualInsetsLeft == 0 + && visualInsetsRight == 0) { + return null; + } + return new OptionalAttributes(outputText, altCode, disabledIconId, visualInsetsLeft, + visualInsetsRight); + } + } + + private final int mHashCode; + + /** The current pressed state of this key */ + private boolean mPressed; + /** Key is enabled and responds on press */ + private boolean mEnabled = true; + + /** + * Constructor for a key on <code>MoreKeyKeyboard</code>, on <code>MoreSuggestions</code>, + * and in a <GridRows/>. + */ + public Key(@Nullable final String label, final int iconId, final int code, + @Nullable final String outputText, @Nullable final String hintLabel, + final int labelFlags, final int backgroundType, final int x, final int y, + final int width, final int height, final int horizontalGap, final int verticalGap) { + mWidth = width - horizontalGap; + mHeight = height - verticalGap; + mHorizontalGap = horizontalGap; + mVerticalGap = verticalGap; + mHintLabel = hintLabel; + mLabelFlags = labelFlags; + mBackgroundType = backgroundType; + // TODO: Pass keyActionFlags as an argument. + mActionFlags = ACTION_FLAGS_NO_KEY_PREVIEW; + mMoreKeys = null; + mMoreKeysColumnAndFlags = 0; + mLabel = label; + mOptionalAttributes = OptionalAttributes.newInstance(outputText, CODE_UNSPECIFIED, + ICON_UNDEFINED, 0 /* visualInsetsLeft */, 0 /* visualInsetsRight */); + mCode = code; + mEnabled = (code != CODE_UNSPECIFIED); + mIconId = iconId; + // Horizontal gap is divided equally to both sides of the key. + mX = x + mHorizontalGap / 2; + mY = y; + mHitBox.set(x, y, x + width + 1, y + height); + mKeyVisualAttributes = null; + + mHashCode = computeHashCode(this); + } + + /** + * Create a key with the given top-left coordinate and extract its attributes from a key + * specification string, Key attribute array, key style, and etc. + * + * @param keySpec the key specification. + * @param keyAttr the Key XML attributes array. + * @param style the {@link KeyStyle} of this key. + * @param params the keyboard building parameters. + * @param row the row that this key belongs to. row's x-coordinate will be the right edge of + * this key. + */ + public Key(@Nullable final String keySpec, @Nonnull final TypedArray keyAttr, + @Nonnull final KeyStyle style, @Nonnull final KeyboardParams params, + @Nonnull final KeyboardRow row) { + mHorizontalGap = isSpacer() ? 0 : params.mHorizontalGap; + mVerticalGap = params.mVerticalGap; + + final float horizontalGapFloat = mHorizontalGap; + final int rowHeight = row.getRowHeight(); + mHeight = rowHeight - mVerticalGap; + + final float keyXPos = row.getKeyX(keyAttr); + final float keyWidth = row.getKeyWidth(keyAttr, keyXPos); + final int keyYPos = row.getKeyY(); + + // Horizontal gap is divided equally to both sides of the key. + mX = Math.round(keyXPos + horizontalGapFloat / 2); + mY = keyYPos; + mWidth = Math.round(keyWidth - horizontalGapFloat); + mHitBox.set(Math.round(keyXPos), keyYPos, Math.round(keyXPos + keyWidth) + 1, + 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 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)); + + mLabelFlags = style.getFlags(keyAttr, R.styleable.Keyboard_Key_keyLabelFlags) + | row.getDefaultKeyLabelFlags(); + final boolean needsToUpcase = needsToUpcase(mLabelFlags, params.mId.mElementId); + final Locale localeForUpcasing = params.mId.getLocale(); + int actionFlags = style.getFlags(keyAttr, R.styleable.Keyboard_Key_keyActionFlags); + String[] moreKeys = style.getStringArray(keyAttr, R.styleable.Keyboard_Key_moreKeys); + + // Get maximum column order number and set a relevant mode value. + int moreKeysColumnAndFlags = MORE_KEYS_MODE_MAX_COLUMN_WITH_AUTO_ORDER + | style.getInt(keyAttr, R.styleable.Keyboard_Key_maxMoreKeysColumn, + params.mMaxMoreKeysKeyboardColumn); + int value; + if ((value = MoreKeySpec.getIntValue(moreKeys, MORE_KEYS_AUTO_COLUMN_ORDER, -1)) > 0) { + // Override with fixed column order number and set a relevant mode value. + moreKeysColumnAndFlags = MORE_KEYS_MODE_FIXED_COLUMN_WITH_AUTO_ORDER + | (value & MORE_KEYS_COLUMN_NUMBER_MASK); + } + if ((value = MoreKeySpec.getIntValue(moreKeys, MORE_KEYS_FIXED_COLUMN_ORDER, -1)) > 0) { + // Override with fixed column order number and set a relevant mode value. + moreKeysColumnAndFlags = MORE_KEYS_MODE_FIXED_COLUMN_WITH_FIXED_ORDER + | (value & MORE_KEYS_COLUMN_NUMBER_MASK); + } + if (MoreKeySpec.getBooleanValue(moreKeys, MORE_KEYS_HAS_LABELS)) { + moreKeysColumnAndFlags |= MORE_KEYS_FLAGS_HAS_LABELS; + } + if (MoreKeySpec.getBooleanValue(moreKeys, MORE_KEYS_NEEDS_DIVIDERS)) { + moreKeysColumnAndFlags |= MORE_KEYS_FLAGS_NEEDS_DIVIDERS; + } + if (MoreKeySpec.getBooleanValue(moreKeys, MORE_KEYS_NO_PANEL_AUTO_MORE_KEY)) { + moreKeysColumnAndFlags |= MORE_KEYS_FLAGS_NO_PANEL_AUTO_MORE_KEY; + } + mMoreKeysColumnAndFlags = moreKeysColumnAndFlags; + + final String[] additionalMoreKeys; + if ((mLabelFlags & LABEL_FLAGS_DISABLE_ADDITIONAL_MORE_KEYS) != 0) { + additionalMoreKeys = null; + } else { + additionalMoreKeys = style.getStringArray(keyAttr, + R.styleable.Keyboard_Key_additionalMoreKeys); + } + moreKeys = MoreKeySpec.insertAdditionalMoreKeys(moreKeys, additionalMoreKeys); + if (moreKeys != null) { + actionFlags |= ACTION_FLAGS_ENABLE_LONG_PRESS; + mMoreKeys = new MoreKeySpec[moreKeys.length]; + for (int i = 0; i < moreKeys.length; i++) { + mMoreKeys[i] = new MoreKeySpec(moreKeys[i], needsToUpcase, localeForUpcasing); + } + } else { + mMoreKeys = null; + } + mActionFlags = actionFlags; + + mIconId = KeySpecParser.getIconId(keySpec); + final int disabledIconId = KeySpecParser.getIconId(style.getString(keyAttr, + R.styleable.Keyboard_Key_keyIconDisabled)); + + final int code = KeySpecParser.getCode(keySpec); + if ((mLabelFlags & LABEL_FLAGS_FROM_CUSTOM_ACTION_LABEL) != 0) { + mLabel = params.mId.mCustomActionLabel; + } else if (code >= Character.MIN_SUPPLEMENTARY_CODE_POINT) { + // This is a workaround to have a key that has a supplementary code point in its label. + // Because we can put a string in resource neither as a XML entity of a supplementary + // code point nor as a surrogate pair. + mLabel = new StringBuilder().appendCodePoint(code).toString(); + } else { + final String label = KeySpecParser.getLabel(keySpec); + mLabel = needsToUpcase + ? StringUtils.toTitleCaseOfKeyLabel(label, localeForUpcasing) + : label; + } + if ((mLabelFlags & LABEL_FLAGS_DISABLE_HINT_LABEL) != 0) { + mHintLabel = null; + } else { + final String hintLabel = style.getString( + keyAttr, R.styleable.Keyboard_Key_keyHintLabel); + mHintLabel = needsToUpcase + ? StringUtils.toTitleCaseOfKeyLabel(hintLabel, localeForUpcasing) + : hintLabel; + } + String outputText = KeySpecParser.getOutputText(keySpec); + if (needsToUpcase) { + outputText = StringUtils.toTitleCaseOfKeyLabel(outputText, localeForUpcasing); + } + // Choose the first letter of the label as primary code if not specified. + if (code == CODE_UNSPECIFIED && TextUtils.isEmpty(outputText) + && !TextUtils.isEmpty(mLabel)) { + if (StringUtils.codePointCount(mLabel) == 1) { + // Use the first letter of the hint label if shiftedLetterActivated flag is + // specified. + if (hasShiftedLetterHint() && isShiftedLetterActivated()) { + mCode = mHintLabel.codePointAt(0); + } else { + mCode = mLabel.codePointAt(0); + } + } else { + // In some locale and case, the character might be represented by multiple code + // points, such as upper case Eszett of German alphabet. + outputText = mLabel; + mCode = CODE_OUTPUT_TEXT; + } + } else if (code == CODE_UNSPECIFIED && outputText != null) { + if (StringUtils.codePointCount(outputText) == 1) { + mCode = outputText.codePointAt(0); + outputText = null; + } else { + mCode = CODE_OUTPUT_TEXT; + } + } else { + mCode = needsToUpcase ? StringUtils.toTitleCaseOfKeyCode(code, localeForUpcasing) + : code; + } + final int altCodeInAttr = KeySpecParser.parseCode( + style.getString(keyAttr, R.styleable.Keyboard_Key_altCode), CODE_UNSPECIFIED); + final int altCode = needsToUpcase + ? StringUtils.toTitleCaseOfKeyCode(altCodeInAttr, localeForUpcasing) + : altCodeInAttr; + mOptionalAttributes = OptionalAttributes.newInstance(outputText, altCode, + disabledIconId, visualInsetsLeft, visualInsetsRight); + mKeyVisualAttributes = KeyVisualAttributes.newInstance(keyAttr); + mHashCode = computeHashCode(this); + } + + /** + * Copy constructor for DynamicGridKeyboard.GridKey. + * + * @param key the original key. + */ + protected Key(@Nonnull final Key key) { + this(key, key.mMoreKeys); + } + + private Key(@Nonnull final Key key, @Nullable final MoreKeySpec[] moreKeys) { + // Final attributes. + mCode = key.mCode; + mLabel = key.mLabel; + mHintLabel = key.mHintLabel; + mLabelFlags = key.mLabelFlags; + mIconId = key.mIconId; + mWidth = key.mWidth; + mHeight = key.mHeight; + mHorizontalGap = key.mHorizontalGap; + mVerticalGap = key.mVerticalGap; + mX = key.mX; + mY = key.mY; + mHitBox.set(key.mHitBox); + mMoreKeys = moreKeys; + mMoreKeysColumnAndFlags = key.mMoreKeysColumnAndFlags; + mBackgroundType = key.mBackgroundType; + mActionFlags = key.mActionFlags; + mKeyVisualAttributes = key.mKeyVisualAttributes; + mOptionalAttributes = key.mOptionalAttributes; + mHashCode = key.mHashCode; + // Key state. + mPressed = key.mPressed; + mEnabled = key.mEnabled; + } + + @Nonnull + public static Key removeRedundantMoreKeys(@Nonnull final Key key, + @Nonnull final MoreKeySpec.LettersOnBaseLayout lettersOnBaseLayout) { + final MoreKeySpec[] moreKeys = key.getMoreKeys(); + final MoreKeySpec[] filteredMoreKeys = MoreKeySpec.removeRedundantMoreKeys( + moreKeys, lettersOnBaseLayout); + return (filteredMoreKeys == moreKeys) ? key : new Key(key, filteredMoreKeys); + } + + private static boolean needsToUpcase(final int labelFlags, final int keyboardElementId) { + if ((labelFlags & LABEL_FLAGS_PRESERVE_CASE) != 0) return false; + switch (keyboardElementId) { + case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: + return true; + default: + return false; + } + } + + private static int computeHashCode(final Key key) { + return Arrays.hashCode(new Object[] { + key.mX, + key.mY, + key.mWidth, + key.mHeight, + key.mCode, + key.mLabel, + key.mHintLabel, + key.mIconId, + key.mBackgroundType, + Arrays.hashCode(key.mMoreKeys), + key.getOutputText(), + key.mActionFlags, + key.mLabelFlags, + // Key can be distinguishable without the following members. + // key.mOptionalAttributes.mAltCode, + // key.mOptionalAttributes.mDisabledIconId, + // key.mOptionalAttributes.mPreviewIconId, + // key.mHorizontalGap, + // key.mVerticalGap, + // key.mOptionalAttributes.mVisualInsetLeft, + // key.mOptionalAttributes.mVisualInsetRight, + // key.mMaxMoreKeysColumn, + }); + } + + private boolean equalsInternal(final Key o) { + if (this == o) return true; + return o.mX == mX + && o.mY == mY + && o.mWidth == mWidth + && o.mHeight == mHeight + && o.mCode == mCode + && TextUtils.equals(o.mLabel, mLabel) + && TextUtils.equals(o.mHintLabel, mHintLabel) + && o.mIconId == mIconId + && o.mBackgroundType == mBackgroundType + && Arrays.equals(o.mMoreKeys, mMoreKeys) + && TextUtils.equals(o.getOutputText(), getOutputText()) + && o.mActionFlags == mActionFlags + && o.mLabelFlags == mLabelFlags; + } + + @Override + public int compareTo(Key o) { + if (equalsInternal(o)) return 0; + if (mHashCode > o.mHashCode) return 1; + return -1; + } + + @Override + public int hashCode() { + return mHashCode; + } + + @Override + public boolean equals(final Object o) { + return o instanceof Key && equalsInternal((Key)o); + } + + @Override + public String toString() { + return toShortString() + " " + getX() + "," + getY() + " " + getWidth() + "x" + getHeight(); + } + + public String toShortString() { + final int code = getCode(); + if (code == Constants.CODE_OUTPUT_TEXT) { + return getOutputText(); + } + return Constants.printableCode(code); + } + + public String toLongString() { + final int iconId = getIconId(); + final String topVisual = (iconId == KeyboardIconsSet.ICON_UNDEFINED) + ? KeyboardIconsSet.PREFIX_ICON + KeyboardIconsSet.getIconName(iconId) : getLabel(); + final String hintLabel = getHintLabel(); + final String visual = (hintLabel == null) ? topVisual : topVisual + "^" + hintLabel; + return toString() + " " + visual + "/" + backgroundName(mBackgroundType); + } + + private static String backgroundName(final int backgroundType) { + switch (backgroundType) { + case BACKGROUND_TYPE_EMPTY: return "empty"; + case BACKGROUND_TYPE_NORMAL: return "normal"; + case BACKGROUND_TYPE_FUNCTIONAL: return "functional"; + case BACKGROUND_TYPE_STICKY_OFF: return "stickyOff"; + case BACKGROUND_TYPE_STICKY_ON: return "stickyOn"; + case BACKGROUND_TYPE_ACTION: return "action"; + case BACKGROUND_TYPE_SPACEBAR: return "spacebar"; + default: return null; + } + } + + public int getCode() { + return mCode; + } + + @Nullable + public String getLabel() { + return mLabel; + } + + @Nullable + public String getHintLabel() { + return mHintLabel; + } + + @Nullable + public MoreKeySpec[] getMoreKeys() { + return mMoreKeys; + } + + public void markAsLeftEdge(final KeyboardParams params) { + mHitBox.left = params.mLeftPadding; + } + + public void markAsRightEdge(final KeyboardParams params) { + mHitBox.right = params.mOccupiedWidth - params.mRightPadding; + } + + public void markAsTopEdge(final KeyboardParams params) { + mHitBox.top = params.mTopPadding; + } + + public void markAsBottomEdge(final KeyboardParams params) { + mHitBox.bottom = params.mOccupiedHeight + params.mBottomPadding; + } + + public final boolean isSpacer() { + return this instanceof Spacer; + } + + public final boolean isActionKey() { + return mBackgroundType == BACKGROUND_TYPE_ACTION; + } + + public final boolean isShift() { + return mCode == CODE_SHIFT; + } + + public final boolean isModifier() { + return mCode == CODE_SHIFT || mCode == CODE_SWITCH_ALPHA_SYMBOL; + } + + public final boolean isRepeatable() { + return (mActionFlags & ACTION_FLAGS_IS_REPEATABLE) != 0; + } + + public final boolean noKeyPreview() { + return (mActionFlags & ACTION_FLAGS_NO_KEY_PREVIEW) != 0; + } + + public final boolean altCodeWhileTyping() { + return (mActionFlags & ACTION_FLAGS_ALT_CODE_WHILE_TYPING) != 0; + } + + public final boolean isLongPressEnabled() { + // We need not start long press timer on the key which has activated shifted letter. + return (mActionFlags & ACTION_FLAGS_ENABLE_LONG_PRESS) != 0 + && (mLabelFlags & LABEL_FLAGS_SHIFTED_LETTER_ACTIVATED) == 0; + } + + public KeyVisualAttributes getVisualAttributes() { + return mKeyVisualAttributes; + } + + @Nonnull + public final Typeface selectTypeface(final KeyDrawParams params) { + switch (mLabelFlags & LABEL_FLAGS_FONT_MASK) { + case LABEL_FLAGS_FONT_NORMAL: + return Typeface.DEFAULT; + case LABEL_FLAGS_FONT_MONO_SPACE: + return Typeface.MONOSPACE; + case LABEL_FLAGS_FONT_DEFAULT: + default: + // The type-face is specified by keyTypeface attribute. + return params.mTypeface; + } + } + + public final int selectTextSize(final KeyDrawParams params) { + switch (mLabelFlags & LABEL_FLAGS_FOLLOW_KEY_TEXT_RATIO_MASK) { + case LABEL_FLAGS_FOLLOW_KEY_LETTER_RATIO: + return params.mLetterSize; + case LABEL_FLAGS_FOLLOW_KEY_LARGE_LETTER_RATIO: + return params.mLargeLetterSize; + case LABEL_FLAGS_FOLLOW_KEY_LABEL_RATIO: + return params.mLabelSize; + case LABEL_FLAGS_FOLLOW_KEY_HINT_LABEL_RATIO: + return params.mHintLabelSize; + default: // No follow key ratio flag specified. + return StringUtils.codePointCount(mLabel) == 1 ? params.mLetterSize : params.mLabelSize; + } + } + + public final int selectTextColor(final KeyDrawParams params) { + if ((mLabelFlags & LABEL_FLAGS_FOLLOW_FUNCTIONAL_TEXT_COLOR) != 0) { + return params.mFunctionalTextColor; + } + return isShiftedLetterActivated() ? params.mTextInactivatedColor : params.mTextColor; + } + + public final int selectHintTextSize(final KeyDrawParams params) { + if (hasHintLabel()) { + return params.mHintLabelSize; + } + if (hasShiftedLetterHint()) { + return params.mShiftedLetterHintSize; + } + return params.mHintLetterSize; + } + + public final int selectHintTextColor(final KeyDrawParams params) { + if (hasHintLabel()) { + return params.mHintLabelColor; + } + if (hasShiftedLetterHint()) { + return isShiftedLetterActivated() ? params.mShiftedLetterHintActivatedColor + : params.mShiftedLetterHintInactivatedColor; + } + return params.mHintLetterColor; + } + + public final int selectMoreKeyTextSize(final KeyDrawParams params) { + return hasLabelsInMoreKeys() ? params.mLabelSize : params.mLetterSize; + } + + public final String getPreviewLabel() { + return isShiftedLetterActivated() ? mHintLabel : mLabel; + } + + private boolean previewHasLetterSize() { + return (mLabelFlags & LABEL_FLAGS_FOLLOW_KEY_LETTER_RATIO) != 0 + || StringUtils.codePointCount(getPreviewLabel()) == 1; + } + + public final int selectPreviewTextSize(final KeyDrawParams params) { + if (previewHasLetterSize()) { + return params.mPreviewTextSize; + } + return params.mLetterSize; + } + + @Nonnull + public Typeface selectPreviewTypeface(final KeyDrawParams params) { + if (previewHasLetterSize()) { + return selectTypeface(params); + } + return Typeface.DEFAULT_BOLD; + } + + public final boolean isAlignHintLabelToBottom(final int defaultFlags) { + return ((mLabelFlags | defaultFlags) & LABEL_FLAGS_ALIGN_HINT_LABEL_TO_BOTTOM) != 0; + } + + public final boolean isAlignIconToBottom() { + return (mLabelFlags & LABEL_FLAGS_ALIGN_ICON_TO_BOTTOM) != 0; + } + + public final boolean isAlignLabelOffCenter() { + return (mLabelFlags & LABEL_FLAGS_ALIGN_LABEL_OFF_CENTER) != 0; + } + + public final boolean hasPopupHint() { + return (mLabelFlags & LABEL_FLAGS_HAS_POPUP_HINT) != 0; + } + + public final boolean hasShiftedLetterHint() { + return (mLabelFlags & LABEL_FLAGS_HAS_SHIFTED_LETTER_HINT) != 0 + && !TextUtils.isEmpty(mHintLabel); + } + + public final boolean hasHintLabel() { + return (mLabelFlags & LABEL_FLAGS_HAS_HINT_LABEL) != 0; + } + + public final boolean needsAutoXScale() { + return (mLabelFlags & LABEL_FLAGS_AUTO_X_SCALE) != 0; + } + + public final boolean needsAutoScale() { + return (mLabelFlags & LABEL_FLAGS_AUTO_SCALE) == LABEL_FLAGS_AUTO_SCALE; + } + + public final boolean needsToKeepBackgroundAspectRatio(final int defaultFlags) { + return ((mLabelFlags | defaultFlags) & LABEL_FLAGS_KEEP_BACKGROUND_ASPECT_RATIO) != 0; + } + + public final boolean hasCustomActionLabel() { + return (mLabelFlags & LABEL_FLAGS_FROM_CUSTOM_ACTION_LABEL) != 0; + } + + private final boolean isShiftedLetterActivated() { + return (mLabelFlags & LABEL_FLAGS_SHIFTED_LETTER_ACTIVATED) != 0 + && !TextUtils.isEmpty(mHintLabel); + } + + public final int getMoreKeysColumnNumber() { + return mMoreKeysColumnAndFlags & MORE_KEYS_COLUMN_NUMBER_MASK; + } + + public final boolean isMoreKeysFixedColumn() { + return (mMoreKeysColumnAndFlags & MORE_KEYS_FLAGS_FIXED_COLUMN) != 0; + } + + public final boolean isMoreKeysFixedOrder() { + return (mMoreKeysColumnAndFlags & MORE_KEYS_FLAGS_FIXED_ORDER) != 0; + } + + public final boolean hasLabelsInMoreKeys() { + return (mMoreKeysColumnAndFlags & MORE_KEYS_FLAGS_HAS_LABELS) != 0; + } + + public final int getMoreKeyLabelFlags() { + final int labelSizeFlag = hasLabelsInMoreKeys() + ? LABEL_FLAGS_FOLLOW_KEY_LABEL_RATIO + : LABEL_FLAGS_FOLLOW_KEY_LETTER_RATIO; + return labelSizeFlag | LABEL_FLAGS_AUTO_X_SCALE; + } + + public final boolean needsDividersInMoreKeys() { + return (mMoreKeysColumnAndFlags & MORE_KEYS_FLAGS_NEEDS_DIVIDERS) != 0; + } + + public final boolean hasNoPanelAutoMoreKey() { + return (mMoreKeysColumnAndFlags & MORE_KEYS_FLAGS_NO_PANEL_AUTO_MORE_KEY) != 0; + } + + @Nullable + public final String getOutputText() { + final OptionalAttributes attrs = mOptionalAttributes; + return (attrs != null) ? attrs.mOutputText : null; + } + + public final int getAltCode() { + final OptionalAttributes attrs = mOptionalAttributes; + return (attrs != null) ? attrs.mAltCode : CODE_UNSPECIFIED; + } + + public int getIconId() { + return mIconId; + } + + @Nullable + public Drawable getIcon(final KeyboardIconsSet iconSet, final int alpha) { + final OptionalAttributes attrs = mOptionalAttributes; + final int disabledIconId = (attrs != null) ? attrs.mDisabledIconId : ICON_UNDEFINED; + final int iconId = mEnabled ? getIconId() : disabledIconId; + final Drawable icon = iconSet.getIconDrawable(iconId); + if (icon != null) { + icon.setAlpha(alpha); + } + return icon; + } + + @Nullable + public Drawable getPreviewIcon(final KeyboardIconsSet iconSet) { + return iconSet.getIconDrawable(getIconId()); + } + + /** + * Gets the width of the key in pixels, excluding the gap. + * @return The width of the key in pixels, excluding the gap. + */ + public int getWidth() { + return mWidth; + } + + /** + * Gets the height of the key in pixels, excluding the gap. + * @return The height of the key in pixels, excluding the gap. + */ + public int getHeight() { + return mHeight; + } + + /** + * The combined width in pixels of the horizontal gaps belonging to this key, both above and + * below. I.e., getWidth() + getHorizontalGap() = total width belonging to the key. + * @return Horizontal gap belonging to this key. + */ + public int getHorizontalGap() { + return mHorizontalGap; + } + + /** + * The combined height in pixels of the vertical gaps belonging to this key, both above and + * below. I.e., getHeight() + getVerticalGap() = total height belonging to the key. + * @return Vertical gap belonging to this key. + */ + public int getVerticalGap() { + return mVerticalGap; + } + + /** + * Gets the x-coordinate of the top-left corner of the key in pixels, excluding the gap. + * @return The x-coordinate of the top-left corner of the key in pixels, excluding the gap. + */ + public int getX() { + return mX; + } + + /** + * Gets the y-coordinate of the top-left corner of the key in pixels, excluding the gap. + * @return The y-coordinate of the top-left corner of the key in pixels, excluding the gap. + */ + public int getY() { + return mY; + } + + public final int getDrawX() { + final int x = getX(); + final OptionalAttributes attrs = mOptionalAttributes; + return (attrs == null) ? x : x + attrs.mVisualInsetsLeft; + } + + public final int getDrawWidth() { + final OptionalAttributes attrs = mOptionalAttributes; + return (attrs == null) ? mWidth + : mWidth - attrs.mVisualInsetsLeft - attrs.mVisualInsetsRight; + } + + /** + * Informs the key that it has been pressed, in case it needs to change its appearance or + * state. + * @see #onReleased() + */ + public void onPressed() { + mPressed = true; + } + + /** + * Informs the key that it has been released, in case it needs to change its appearance or + * state. + * @see #onPressed() + */ + public void onReleased() { + mPressed = false; + } + + public final boolean isEnabled() { + return mEnabled; + } + + public void setEnabled(final boolean enabled) { + mEnabled = enabled; + } + + @Nonnull + public Rect getHitBox() { + return mHitBox; + } + + /** + * Detects if a point falls on this key. + * @param x the x-coordinate of the point + * @param y the y-coordinate of the point + * @return whether or not the point falls on the key. If the key is attached to an edge, it + * will assume that all points between the key and the edge are considered to be on the key. + * @see #markAsLeftEdge(KeyboardParams) etc. + */ + public boolean isOnKey(final int x, final int y) { + return mHitBox.contains(x, y); + } + + /** + * Returns the square of the distance to the nearest edge of the key and the given point. + * @param x the x-coordinate of the point + * @param y the y-coordinate of the point + * @return the square of the distance of the point from the nearest edge of the key + */ + public int squaredDistanceToEdge(final int x, final int y) { + final int left = getX(); + final int right = left + mWidth; + final int top = getY(); + final int bottom = top + mHeight; + final int edgeX = x < left ? left : (x > right ? right : x); + final int edgeY = y < top ? top : (y > bottom ? bottom : y); + final int dx = x - edgeX; + final int dy = y - edgeY; + return dx * dx + dy * dy; + } + + static class KeyBackgroundState { + private final int[] mReleasedState; + private final int[] mPressedState; + + private KeyBackgroundState(final int ... attrs) { + mReleasedState = attrs; + mPressedState = Arrays.copyOf(attrs, attrs.length + 1); + mPressedState[attrs.length] = android.R.attr.state_pressed; + } + + public int[] getState(final boolean pressed) { + return pressed ? mPressedState : mReleasedState; + } + + public static final KeyBackgroundState[] STATES = { + // 0: BACKGROUND_TYPE_EMPTY + new KeyBackgroundState(android.R.attr.state_empty), + // 1: BACKGROUND_TYPE_NORMAL + new KeyBackgroundState(), + // 2: BACKGROUND_TYPE_FUNCTIONAL + new KeyBackgroundState(), + // 3: BACKGROUND_TYPE_STICKY_OFF + new KeyBackgroundState(android.R.attr.state_checkable), + // 4: BACKGROUND_TYPE_STICKY_ON + new KeyBackgroundState(android.R.attr.state_checkable, android.R.attr.state_checked), + // 5: BACKGROUND_TYPE_ACTION + new KeyBackgroundState(android.R.attr.state_active), + // 6: BACKGROUND_TYPE_SPACEBAR + new KeyBackgroundState(), + }; + } + + /** + * Returns the background drawable for the key, based on the current state and type of the key. + * @return the background drawable of the key. + * @see android.graphics.drawable.StateListDrawable#setState(int[]) + */ + @Nonnull + public final Drawable selectBackgroundDrawable(@Nonnull final Drawable keyBackground, + @Nonnull final Drawable functionalKeyBackground, + @Nonnull final Drawable spacebarBackground) { + final Drawable background; + if (mBackgroundType == BACKGROUND_TYPE_FUNCTIONAL) { + background = functionalKeyBackground; + } else if (mBackgroundType == BACKGROUND_TYPE_SPACEBAR) { + background = spacebarBackground; + } else { + background = keyBackground; + } + final int[] state = KeyBackgroundState.STATES[mBackgroundType].getState(mPressed); + background.setState(state); + return background; + } + + public static class Spacer extends Key { + public Spacer(final TypedArray keyAttr, final KeyStyle keyStyle, + final KeyboardParams params, final KeyboardRow row) { + super(null /* keySpec */, keyAttr, keyStyle, params, row); + } + + /** + * This constructor is being used only for divider in more keys keyboard. + */ + protected Spacer(final KeyboardParams params, final int x, final int y, final int width, + final int height) { + super(null /* label */, ICON_UNDEFINED, CODE_UNSPECIFIED, null /* outputText */, + null /* hintLabel */, 0 /* labelFlags */, BACKGROUND_TYPE_EMPTY, x, y, width, + height, params.mHorizontalGap, params.mVerticalGap); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyDetector.java b/java/src/org/kelar/inputmethod/keyboard/KeyDetector.java new file mode 100644 index 000000000..9fa18ee3f --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyDetector.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +/** + * This class handles key detection. + */ +public class KeyDetector { + private final int mKeyHysteresisDistanceSquared; + private final int mKeyHysteresisDistanceForSlidingModifierSquared; + + private Keyboard mKeyboard; + private int mCorrectionX; + private int mCorrectionY; + + public KeyDetector() { + this(0.0f /* keyHysteresisDistance */, 0.0f /* keyHysteresisDistanceForSlidingModifier */); + } + + /** + * Key detection object constructor with key hysteresis distances. + * + * @param keyHysteresisDistance if the pointer movement distance is smaller than this, the + * movement will not be handled as meaningful movement. The unit is pixel. + * @param keyHysteresisDistanceForSlidingModifier the same parameter for sliding input that + * starts from a modifier key such as shift and symbols key. + */ + public KeyDetector(final float keyHysteresisDistance, + final float keyHysteresisDistanceForSlidingModifier) { + mKeyHysteresisDistanceSquared = (int)(keyHysteresisDistance * keyHysteresisDistance); + mKeyHysteresisDistanceForSlidingModifierSquared = (int)( + keyHysteresisDistanceForSlidingModifier * keyHysteresisDistanceForSlidingModifier); + } + + public void setKeyboard(final Keyboard keyboard, final float correctionX, + final float correctionY) { + if (keyboard == null) { + throw new NullPointerException(); + } + mCorrectionX = (int)correctionX; + mCorrectionY = (int)correctionY; + mKeyboard = keyboard; + } + + public int getKeyHysteresisDistanceSquared(final boolean isSlidingFromModifier) { + return isSlidingFromModifier + ? mKeyHysteresisDistanceForSlidingModifierSquared : mKeyHysteresisDistanceSquared; + } + + public int getTouchX(final int x) { + return x + mCorrectionX; + } + + // TODO: Remove vertical correction. + public int getTouchY(final int y) { + return y + mCorrectionY; + } + + public Keyboard getKeyboard() { + return mKeyboard; + } + + public boolean alwaysAllowsKeySelectionByDraggingFinger() { + return false; + } + + /** + * Detect the key whose hitbox the touch point is in. + * + * @param x The x-coordinate of a touch point + * @param y The y-coordinate of a touch point + * @return the key that the touch point hits. + */ + public Key detectHitKey(final int x, final int y) { + if (mKeyboard == null) { + return null; + } + final int touchX = getTouchX(x); + final int touchY = getTouchY(y); + + int minDistance = Integer.MAX_VALUE; + Key primaryKey = null; + for (final Key key: mKeyboard.getNearestKeys(touchX, touchY)) { + // An edge key always has its enlarged hitbox to respond to an event that occurred in + // the empty area around the key. (@see Key#markAsLeftEdge(KeyboardParams)} etc.) + if (!key.isOnKey(touchX, touchY)) { + continue; + } + final int distance = key.squaredDistanceToEdge(touchX, touchY); + if (distance > minDistance) { + continue; + } + // To take care of hitbox overlaps, we compare key's code here too. + if (primaryKey == null || distance < minDistance + || key.getCode() > primaryKey.getCode()) { + minDistance = distance; + primaryKey = key; + } + } + return primaryKey; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/Keyboard.java b/java/src/org/kelar/inputmethod/keyboard/Keyboard.java new file mode 100644 index 000000000..bac7587db --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/Keyboard.java @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +import android.util.SparseArray; + +import org.kelar.inputmethod.keyboard.internal.KeyVisualAttributes; +import org.kelar.inputmethod.keyboard.internal.KeyboardIconsSet; +import org.kelar.inputmethod.keyboard.internal.KeyboardParams; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Loads an XML description of a keyboard and stores the attributes of the keys. A keyboard + * consists of rows of keys. + * <p>The layout file for a keyboard contains XML that looks like the following snippet:</p> + * <pre> + * <Keyboard + * 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> + * ... + * </Keyboard> + * </pre> + */ +public class Keyboard { + @Nonnull + public final KeyboardId mId; + public final int mThemeId; + + /** Total height of the keyboard, including the padding and keys */ + public final int mOccupiedHeight; + /** Total width of the keyboard, including the padding and keys */ + public final int mOccupiedWidth; + + /** Base height of the keyboard, used to calculate rows' height */ + public final int mBaseHeight; + /** Base width of the keyboard, used to calculate keys' width */ + public final int mBaseWidth; + + /** The padding above the keyboard */ + public final int mTopPadding; + /** Default gap between rows */ + public final int mVerticalGap; + + /** Per keyboard key visual parameters */ + public final KeyVisualAttributes mKeyVisualAttributes; + + public final int mMostCommonKeyHeight; + public final int mMostCommonKeyWidth; + + /** More keys keyboard template */ + public final int mMoreKeysTemplate; + + /** Maximum column for more keys keyboard */ + public final int mMaxMoreKeysKeyboardColumn; + + /** List of keys in this keyboard */ + @Nonnull + private final List<Key> mSortedKeys; + @Nonnull + public final List<Key> mShiftKeys; + @Nonnull + public final List<Key> mAltCodeKeysWhileTyping; + @Nonnull + public final KeyboardIconsSet mIconsSet; + + private final SparseArray<Key> mKeyCache = new SparseArray<>(); + + @Nonnull + private final ProximityInfo mProximityInfo; + @Nonnull + private final KeyboardLayout mKeyboardLayout; + + private final boolean mProximityCharsCorrectionEnabled; + + public Keyboard(@Nonnull final KeyboardParams params) { + mId = params.mId; + mThemeId = params.mThemeId; + mOccupiedHeight = params.mOccupiedHeight; + mOccupiedWidth = params.mOccupiedWidth; + mBaseHeight = params.mBaseHeight; + mBaseWidth = params.mBaseWidth; + mMostCommonKeyHeight = params.mMostCommonKeyHeight; + mMostCommonKeyWidth = params.mMostCommonKeyWidth; + mMoreKeysTemplate = params.mMoreKeysTemplate; + mMaxMoreKeysKeyboardColumn = params.mMaxMoreKeysKeyboardColumn; + mKeyVisualAttributes = params.mKeyVisualAttributes; + mTopPadding = params.mTopPadding; + mVerticalGap = params.mVerticalGap; + + mSortedKeys = Collections.unmodifiableList(new ArrayList<>(params.mSortedKeys)); + mShiftKeys = Collections.unmodifiableList(params.mShiftKeys); + mAltCodeKeysWhileTyping = Collections.unmodifiableList(params.mAltCodeKeysWhileTyping); + mIconsSet = params.mIconsSet; + + mProximityInfo = new ProximityInfo(params.GRID_WIDTH, params.GRID_HEIGHT, + mOccupiedWidth, mOccupiedHeight, mMostCommonKeyWidth, mMostCommonKeyHeight, + mSortedKeys, params.mTouchPositionCorrection); + mProximityCharsCorrectionEnabled = params.mProximityCharsCorrectionEnabled; + mKeyboardLayout = KeyboardLayout.newKeyboardLayout(mSortedKeys, mMostCommonKeyWidth, + mMostCommonKeyHeight, mOccupiedWidth, mOccupiedHeight); + } + + protected Keyboard(@Nonnull final Keyboard keyboard) { + mId = keyboard.mId; + mThemeId = keyboard.mThemeId; + mOccupiedHeight = keyboard.mOccupiedHeight; + mOccupiedWidth = keyboard.mOccupiedWidth; + mBaseHeight = keyboard.mBaseHeight; + mBaseWidth = keyboard.mBaseWidth; + mMostCommonKeyHeight = keyboard.mMostCommonKeyHeight; + mMostCommonKeyWidth = keyboard.mMostCommonKeyWidth; + mMoreKeysTemplate = keyboard.mMoreKeysTemplate; + mMaxMoreKeysKeyboardColumn = keyboard.mMaxMoreKeysKeyboardColumn; + mKeyVisualAttributes = keyboard.mKeyVisualAttributes; + mTopPadding = keyboard.mTopPadding; + mVerticalGap = keyboard.mVerticalGap; + + mSortedKeys = keyboard.mSortedKeys; + mShiftKeys = keyboard.mShiftKeys; + mAltCodeKeysWhileTyping = keyboard.mAltCodeKeysWhileTyping; + mIconsSet = keyboard.mIconsSet; + + mProximityInfo = keyboard.mProximityInfo; + mProximityCharsCorrectionEnabled = keyboard.mProximityCharsCorrectionEnabled; + mKeyboardLayout = keyboard.mKeyboardLayout; + } + + public boolean hasProximityCharsCorrection(final int code) { + if (!mProximityCharsCorrectionEnabled) { + return false; + } + // Note: The native code has the main keyboard layout only at this moment. + // TODO: Figure out how to handle proximity characters information of all layouts. + final boolean canAssumeNativeHasProximityCharsInfoOfAllKeys = ( + mId.mElementId == KeyboardId.ELEMENT_ALPHABET + || mId.mElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED); + return canAssumeNativeHasProximityCharsInfoOfAllKeys || Character.isLetter(code); + } + + @Nonnull + public ProximityInfo getProximityInfo() { + return mProximityInfo; + } + + @Nonnull + public KeyboardLayout getKeyboardLayout() { + return mKeyboardLayout; + } + + /** + * Return the sorted list of keys of this keyboard. + * The keys are sorted from top-left to bottom-right order. + * The list may contain {@link Key.Spacer} object as well. + * @return the sorted unmodifiable list of {@link Key}s of this keyboard. + */ + @Nonnull + public List<Key> getSortedKeys() { + return mSortedKeys; + } + + @Nullable + public Key getKey(final int code) { + if (code == Constants.CODE_UNSPECIFIED) { + return null; + } + synchronized (mKeyCache) { + final int index = mKeyCache.indexOfKey(code); + if (index >= 0) { + return mKeyCache.valueAt(index); + } + + for (final Key key : getSortedKeys()) { + if (key.getCode() == code) { + mKeyCache.put(code, key); + return key; + } + } + mKeyCache.put(code, null); + return null; + } + } + + public boolean hasKey(@Nonnull final Key aKey) { + if (mKeyCache.indexOfValue(aKey) >= 0) { + return true; + } + + for (final Key key : getSortedKeys()) { + if (key == aKey) { + mKeyCache.put(key.getCode(), key); + return true; + } + } + return false; + } + + @Override + public String toString() { + return mId.toString(); + } + + /** + * Returns the array of the keys that are closest to the given point. + * @param x the x-coordinate of the point + * @param y the y-coordinate of the point + * @return the list of the nearest keys to the given point. If the given + * point is out of range, then an array of size zero is returned. + */ + @Nonnull + public List<Key> getNearestKeys(final int x, final int y) { + // Avoid dead pixels at edges of the keyboard + final int adjustedX = Math.max(0, Math.min(x, mOccupiedWidth - 1)); + final int adjustedY = Math.max(0, Math.min(y, mOccupiedHeight - 1)); + return mProximityInfo.getNearestKeys(adjustedX, adjustedY); + } + + @Nonnull + public int[] getCoordinates(@Nonnull final int[] codePoints) { + final int length = codePoints.length; + final int[] coordinates = CoordinateUtils.newCoordinateArray(length); + for (int i = 0; i < length; ++i) { + final Key key = getKey(codePoints[i]); + if (null != key) { + CoordinateUtils.setXYInArray(coordinates, i, + key.getX() + key.getWidth() / 2, key.getY() + key.getHeight() / 2); + } else { + CoordinateUtils.setXYInArray(coordinates, i, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE); + } + } + return coordinates; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardActionListener.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardActionListener.java new file mode 100644 index 000000000..38ec5b808 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardActionListener.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.InputPointers; + +public interface KeyboardActionListener { + /** + * Called when the user presses a key. This is sent before the {@link #onCodeInput} is called. + * For keys that repeat, this is only called once. + * + * @param primaryCode the unicode of the key being pressed. If the touch is not on a valid key, + * the value will be zero. + * @param repeatCount how many times the key was repeated. Zero if it is the first press. + * @param isSinglePointer true if pressing has occurred while no other key is being pressed. + */ + public void onPressKey(int primaryCode, int repeatCount, boolean isSinglePointer); + + /** + * Called when the user releases a key. This is sent after the {@link #onCodeInput} is called. + * For keys that repeat, this is only called once. + * + * @param primaryCode the code of the key that was released + * @param withSliding true if releasing has occurred because the user slid finger from the key + * to other key without releasing the finger. + */ + public void onReleaseKey(int primaryCode, boolean withSliding); + + /** + * Send a key code to the listener. + * + * @param primaryCode this is the code of the key that was pressed + * @param x x-coordinate pixel of touched event. If {@link #onCodeInput} is not called by + * {@link PointerTracker} or so, the value should be + * {@link Constants#NOT_A_COORDINATE}. If it's called on insertion from the + * suggestion strip, it should be {@link Constants#SUGGESTION_STRIP_COORDINATE}. + * @param y y-coordinate pixel of touched event. If {@link #onCodeInput} is not called by + * {@link PointerTracker} or so, the value should be + * {@link Constants#NOT_A_COORDINATE}.If it's called on insertion from the + * suggestion strip, it should be {@link Constants#SUGGESTION_STRIP_COORDINATE}. + * @param isKeyRepeat true if this is a key repeat, false otherwise + */ + // TODO: change this to send an Event object instead + public void onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat); + + /** + * Sends a string of characters to the listener. + * + * @param text the string of characters to be registered. + */ + public void onTextInput(String text); + + /** + * Called when user started batch input. + */ + public void onStartBatchInput(); + + /** + * Sends the ongoing batch input points data. + * @param batchPointers the batch input points representing the user input + */ + public void onUpdateBatchInput(InputPointers batchPointers); + + /** + * Sends the final batch input points data. + * + * @param batchPointers the batch input points representing the user input + */ + public void onEndBatchInput(InputPointers batchPointers); + + public void onCancelBatchInput(); + + /** + * Called when user released a finger outside any key. + */ + public void onCancelInput(); + + /** + * Called when user finished sliding key input. + */ + public void onFinishSlidingInput(); + + /** + * Send a non-"code input" custom request to the listener. + * @return true if the request has been consumed, false otherwise. + */ + public boolean onCustomRequest(int requestCode); + + public static final KeyboardActionListener EMPTY_LISTENER = new Adapter(); + + public static class Adapter implements KeyboardActionListener { + @Override + public void onPressKey(int primaryCode, int repeatCount, boolean isSinglePointer) {} + @Override + public void onReleaseKey(int primaryCode, boolean withSliding) {} + @Override + public void onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat) {} + @Override + public void onTextInput(String text) {} + @Override + public void onStartBatchInput() {} + @Override + public void onUpdateBatchInput(InputPointers batchPointers) {} + @Override + public void onEndBatchInput(InputPointers batchPointers) {} + @Override + public void onCancelBatchInput() {} + @Override + public void onCancelInput() {} + @Override + public void onFinishSlidingInput() {} + @Override + public boolean onCustomRequest(int requestCode) { + return false; + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardId.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardId.java new file mode 100644 index 000000000..f8e98d2b1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardId.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.keyboard; + +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET; + +import android.text.InputType; +import android.text.TextUtils; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.compat.EditorInfoCompatUtils; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.utils.InputTypeUtils; + +import java.util.Arrays; +import java.util.Locale; + +/** + * Unique identifier for each keyboard type. + */ +public final class KeyboardId { + public static final int MODE_TEXT = 0; + public static final int MODE_URL = 1; + public static final int MODE_EMAIL = 2; + public static final int MODE_IM = 3; + public static final int MODE_PHONE = 4; + public static final int MODE_NUMBER = 5; + public static final int MODE_DATE = 6; + public static final int MODE_TIME = 7; + public static final int MODE_DATETIME = 8; + + public static final int ELEMENT_ALPHABET = 0; + public static final int ELEMENT_ALPHABET_MANUAL_SHIFTED = 1; + public static final int ELEMENT_ALPHABET_AUTOMATIC_SHIFTED = 2; + public static final int ELEMENT_ALPHABET_SHIFT_LOCKED = 3; + public static final int ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED = 4; + public static final int ELEMENT_SYMBOLS = 5; + public static final int ELEMENT_SYMBOLS_SHIFTED = 6; + public static final int ELEMENT_PHONE = 7; + public static final int ELEMENT_PHONE_SYMBOLS = 8; + public static final int ELEMENT_NUMBER = 9; + public static final int ELEMENT_EMOJI_RECENTS = 10; + public static final int ELEMENT_EMOJI_CATEGORY1 = 11; + public static final int ELEMENT_EMOJI_CATEGORY2 = 12; + public static final int ELEMENT_EMOJI_CATEGORY3 = 13; + public static final int ELEMENT_EMOJI_CATEGORY4 = 14; + public static final int ELEMENT_EMOJI_CATEGORY5 = 15; + public static final int ELEMENT_EMOJI_CATEGORY6 = 16; + public static final int ELEMENT_EMOJI_CATEGORY7 = 17; + public static final int ELEMENT_EMOJI_CATEGORY8 = 18; + public static final int ELEMENT_EMOJI_CATEGORY9 = 19; + public static final int ELEMENT_EMOJI_CATEGORY10 = 20; + public static final int ELEMENT_EMOJI_CATEGORY11 = 21; + public static final int ELEMENT_EMOJI_CATEGORY12 = 22; + public static final int ELEMENT_EMOJI_CATEGORY13 = 23; + public static final int ELEMENT_EMOJI_CATEGORY14 = 24; + public static final int ELEMENT_EMOJI_CATEGORY15 = 25; + public static final int ELEMENT_EMOJI_CATEGORY16 = 26; + + public final RichInputMethodSubtype mSubtype; + public final int mWidth; + public final int mHeight; + public final int mMode; + public final int mElementId; + public final EditorInfo mEditorInfo; + public final boolean mClobberSettingsKey; + public final boolean mLanguageSwitchKeyEnabled; + public final String mCustomActionLabel; + public final boolean mHasShortcutKey; + public final boolean mIsSplitLayout; + + private final int mHashCode; + + public KeyboardId(final int elementId, final KeyboardLayoutSet.Params params) { + mSubtype = params.mSubtype; + mWidth = params.mKeyboardWidth; + mHeight = params.mKeyboardHeight; + mMode = params.mMode; + mElementId = elementId; + mEditorInfo = params.mEditorInfo; + mClobberSettingsKey = params.mNoSettingsKey; + mLanguageSwitchKeyEnabled = params.mLanguageSwitchKeyEnabled; + mCustomActionLabel = (mEditorInfo.actionLabel != null) + ? mEditorInfo.actionLabel.toString() : null; + mHasShortcutKey = params.mVoiceInputKeyEnabled; + mIsSplitLayout = params.mIsSplitLayoutEnabled; + + mHashCode = computeHashCode(this); + } + + private static int computeHashCode(final KeyboardId id) { + return Arrays.hashCode(new Object[] { + id.mElementId, + id.mMode, + id.mWidth, + id.mHeight, + id.passwordInput(), + id.mClobberSettingsKey, + id.mHasShortcutKey, + id.mLanguageSwitchKeyEnabled, + id.isMultiLine(), + id.imeAction(), + id.mCustomActionLabel, + id.navigateNext(), + id.navigatePrevious(), + id.mSubtype, + id.mIsSplitLayout + }); + } + + private boolean equals(final KeyboardId other) { + if (other == this) + return true; + return other.mElementId == mElementId + && other.mMode == mMode + && other.mWidth == mWidth + && other.mHeight == mHeight + && other.passwordInput() == passwordInput() + && other.mClobberSettingsKey == mClobberSettingsKey + && other.mHasShortcutKey == mHasShortcutKey + && other.mLanguageSwitchKeyEnabled == mLanguageSwitchKeyEnabled + && other.isMultiLine() == isMultiLine() + && other.imeAction() == imeAction() + && TextUtils.equals(other.mCustomActionLabel, mCustomActionLabel) + && other.navigateNext() == navigateNext() + && other.navigatePrevious() == navigatePrevious() + && other.mSubtype.equals(mSubtype) + && other.mIsSplitLayout == mIsSplitLayout; + } + + private static boolean isAlphabetKeyboard(final int elementId) { + return elementId < ELEMENT_SYMBOLS; + } + + public boolean isAlphabetKeyboard() { + return isAlphabetKeyboard(mElementId); + } + + public boolean navigateNext() { + return (mEditorInfo.imeOptions & EditorInfo.IME_FLAG_NAVIGATE_NEXT) != 0 + || imeAction() == EditorInfo.IME_ACTION_NEXT; + } + + public boolean navigatePrevious() { + return (mEditorInfo.imeOptions & EditorInfo.IME_FLAG_NAVIGATE_PREVIOUS) != 0 + || imeAction() == EditorInfo.IME_ACTION_PREVIOUS; + } + + public boolean passwordInput() { + final int inputType = mEditorInfo.inputType; + return InputTypeUtils.isPasswordInputType(inputType) + || InputTypeUtils.isVisiblePasswordInputType(inputType); + } + + public boolean isMultiLine() { + return (mEditorInfo.inputType & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0; + } + + public int imeAction() { + return InputTypeUtils.getImeOptionsActionIdFromEditorInfo(mEditorInfo); + } + + public Locale getLocale() { + return mSubtype.getLocale(); + } + + @Override + public boolean equals(final Object other) { + return other instanceof KeyboardId && equals((KeyboardId) other); + } + + @Override + public int hashCode() { + return mHashCode; + } + + @Override + public String toString() { + return String.format(Locale.ROOT, "[%s %s:%s %dx%d %s %s%s%s%s%s%s%s%s%s]", + elementIdToName(mElementId), + mSubtype.getLocale(), + mSubtype.getExtraValueOf(KEYBOARD_LAYOUT_SET), + mWidth, mHeight, + modeName(mMode), + actionName(imeAction()), + (navigateNext() ? " navigateNext" : ""), + (navigatePrevious() ? " navigatePrevious" : ""), + (mClobberSettingsKey ? " clobberSettingsKey" : ""), + (passwordInput() ? " passwordInput" : ""), + (mHasShortcutKey ? " hasShortcutKey" : ""), + (mLanguageSwitchKeyEnabled ? " languageSwitchKeyEnabled" : ""), + (isMultiLine() ? " isMultiLine" : ""), + (mIsSplitLayout ? " isSplitLayout" : "") + ); + } + + public static boolean equivalentEditorInfoForKeyboard(final EditorInfo a, final EditorInfo b) { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return a.inputType == b.inputType + && a.imeOptions == b.imeOptions + && TextUtils.equals(a.privateImeOptions, b.privateImeOptions); + } + + public static String elementIdToName(final int elementId) { + switch (elementId) { + case ELEMENT_ALPHABET: return "alphabet"; + case ELEMENT_ALPHABET_MANUAL_SHIFTED: return "alphabetManualShifted"; + case ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: return "alphabetAutomaticShifted"; + case ELEMENT_ALPHABET_SHIFT_LOCKED: return "alphabetShiftLocked"; + case ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: return "alphabetShiftLockShifted"; + case ELEMENT_SYMBOLS: return "symbols"; + case ELEMENT_SYMBOLS_SHIFTED: return "symbolsShifted"; + case ELEMENT_PHONE: return "phone"; + case ELEMENT_PHONE_SYMBOLS: return "phoneSymbols"; + case ELEMENT_NUMBER: return "number"; + case ELEMENT_EMOJI_RECENTS: return "emojiRecents"; + case ELEMENT_EMOJI_CATEGORY1: return "emojiCategory1"; + case ELEMENT_EMOJI_CATEGORY2: return "emojiCategory2"; + case ELEMENT_EMOJI_CATEGORY3: return "emojiCategory3"; + case ELEMENT_EMOJI_CATEGORY4: return "emojiCategory4"; + case ELEMENT_EMOJI_CATEGORY5: return "emojiCategory5"; + case ELEMENT_EMOJI_CATEGORY6: return "emojiCategory6"; + case ELEMENT_EMOJI_CATEGORY7: return "emojiCategory7"; + case ELEMENT_EMOJI_CATEGORY8: return "emojiCategory8"; + case ELEMENT_EMOJI_CATEGORY9: return "emojiCategory9"; + case ELEMENT_EMOJI_CATEGORY10: return "emojiCategory10"; + case ELEMENT_EMOJI_CATEGORY11: return "emojiCategory11"; + case ELEMENT_EMOJI_CATEGORY12: return "emojiCategory12"; + case ELEMENT_EMOJI_CATEGORY13: return "emojiCategory13"; + case ELEMENT_EMOJI_CATEGORY14: return "emojiCategory14"; + case ELEMENT_EMOJI_CATEGORY15: return "emojiCategory15"; + case ELEMENT_EMOJI_CATEGORY16: return "emojiCategory16"; + default: return null; + } + } + + public static String modeName(final int mode) { + switch (mode) { + case MODE_TEXT: return "text"; + case MODE_URL: return "url"; + case MODE_EMAIL: return "email"; + case MODE_IM: return "im"; + case MODE_PHONE: return "phone"; + case MODE_NUMBER: return "number"; + case MODE_DATE: return "date"; + case MODE_TIME: return "time"; + case MODE_DATETIME: return "datetime"; + default: return null; + } + } + + public static String actionName(final int actionId) { + return (actionId == InputTypeUtils.IME_ACTION_CUSTOM_LABEL) ? "actionCustomLabel" + : EditorInfoCompatUtils.imeActionName(actionId); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardLayout.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardLayout.java new file mode 100644 index 000000000..677a3560c --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardLayout.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.keyboard; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; + +/** + * KeyboardLayout maintains the keyboard layout information. + */ +public class KeyboardLayout { + + private final int[] mKeyCodes; + + private final int[] mKeyXCoordinates; + private final int[] mKeyYCoordinates; + + private final int[] mKeyWidths; + private final int[] mKeyHeights; + + public final int mMostCommonKeyWidth; + public final int mMostCommonKeyHeight; + + public final int mKeyboardWidth; + public final int mKeyboardHeight; + + public KeyboardLayout(ArrayList<Key> layoutKeys, int mostCommonKeyWidth, + int mostCommonKeyHeight, int keyboardWidth, int keyboardHeight) { + mMostCommonKeyWidth = mostCommonKeyWidth; + mMostCommonKeyHeight = mostCommonKeyHeight; + mKeyboardWidth = keyboardWidth; + mKeyboardHeight = keyboardHeight; + + mKeyCodes = new int[layoutKeys.size()]; + mKeyXCoordinates = new int[layoutKeys.size()]; + mKeyYCoordinates = new int[layoutKeys.size()]; + mKeyWidths = new int[layoutKeys.size()]; + mKeyHeights = new int[layoutKeys.size()]; + + for (int i = 0; i < layoutKeys.size(); i++) { + Key key = layoutKeys.get(i); + mKeyCodes[i] = Character.toLowerCase(key.getCode()); + mKeyXCoordinates[i] = key.getX(); + mKeyYCoordinates[i] = key.getY(); + mKeyWidths[i] = key.getWidth(); + mKeyHeights[i] = key.getHeight(); + } + } + + @UsedForTesting + public int[] getKeyCodes() { + return mKeyCodes; + } + + /** + * The x-coordinate for the top-left corner of the keys. + * + */ + public int[] getKeyXCoordinates() { + return mKeyXCoordinates; + } + + /** + * The y-coordinate for the top-left corner of the keys. + */ + public int[] getKeyYCoordinates() { + return mKeyYCoordinates; + } + + /** + * The widths of the keys which are smaller than the true hit-area due to the gaps + * between keys. The mostCommonKey(Width/Height) represents the true key width/height + * including the gaps. + */ + public int[] getKeyWidths() { + return mKeyWidths; + } + + /** + * The heights of the keys which are smaller than the true hit-area due to the gaps + * between keys. The mostCommonKey(Width/Height) represents the true key width/height + * including the gaps. + */ + public int[] getKeyHeights() { + return mKeyHeights; + } + + /** + * Factory method to create {@link KeyboardLayout} objects. + */ + public static KeyboardLayout newKeyboardLayout(@Nonnull final List<Key> sortedKeys, + int mostCommonKeyWidth, int mostCommonKeyHeight, + int occupiedWidth, int occupiedHeight) { + final ArrayList<Key> layoutKeys = new ArrayList<Key>(); + for (final Key key : sortedKeys) { + if (!ProximityInfo.needsProximityInfo(key)) { + continue; + } + if (key.getCode() != ',') { + layoutKeys.add(key); + } + } + return new KeyboardLayout(layoutKeys, mostCommonKeyWidth, + mostCommonKeyHeight, occupiedWidth, occupiedHeight); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardLayoutSet.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardLayoutSet.java new file mode 100644 index 000000000..0350336a9 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardLayoutSet.java @@ -0,0 +1,507 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.FORCE_ASCII; +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_SETTINGS_KEY; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.text.InputType; +import android.util.Log; +import android.util.SparseArray; +import android.util.Xml; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.compat.EditorInfoCompatUtils; +import org.kelar.inputmethod.compat.InputMethodSubtypeCompatUtils; +import org.kelar.inputmethod.compat.UserManagerCompatUtils; +import org.kelar.inputmethod.keyboard.internal.KeyboardBuilder; +import org.kelar.inputmethod.keyboard.internal.KeyboardParams; +import org.kelar.inputmethod.keyboard.internal.UniqueKeysCache; +import org.kelar.inputmethod.latin.InputAttributes; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.utils.InputTypeUtils; +import org.kelar.inputmethod.latin.utils.ScriptUtils; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; +import org.kelar.inputmethod.latin.utils.XmlParseUtils; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.util.HashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * This class represents a set of keyboard layouts. Each of them represents a different keyboard + * specific to a keyboard state, such as alphabet, symbols, and so on. Layouts in the same + * {@link KeyboardLayoutSet} are related to each other. + * A {@link KeyboardLayoutSet} needs to be created for each + * {@link android.view.inputmethod.EditorInfo}. + */ +public final class KeyboardLayoutSet { + private static final String TAG = KeyboardLayoutSet.class.getSimpleName(); + private static final boolean DEBUG_CACHE = false; + + private static final String TAG_KEYBOARD_SET = "KeyboardLayoutSet"; + private static final String TAG_ELEMENT = "Element"; + private static final String TAG_FEATURE = "Feature"; + + private static final String KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX = "keyboard_layout_set_"; + + private final Context mContext; + @Nonnull + private final Params mParams; + + // How many layouts we forcibly keep in cache. This only includes ALPHABET (default) and + // ALPHABET_AUTOMATIC_SHIFTED layouts - other layouts may stay in memory in the map of + // soft-references, but we forcibly cache this many alphabetic/auto-shifted layouts. + private static final int FORCIBLE_CACHE_SIZE = 4; + // By construction of soft references, anything that is also referenced somewhere else + // will stay in the cache. So we forcibly keep some references in an array to prevent + // them from disappearing from sKeyboardCache. + private static final Keyboard[] sForcibleKeyboardCache = new Keyboard[FORCIBLE_CACHE_SIZE]; + private static final HashMap<KeyboardId, SoftReference<Keyboard>> sKeyboardCache = + new HashMap<>(); + @Nonnull + private static final UniqueKeysCache sUniqueKeysCache = UniqueKeysCache.newInstance(); + private final static HashMap<InputMethodSubtype, Integer> sScriptIdsForSubtypes = + new HashMap<>(); + + @SuppressWarnings("serial") + public static final class KeyboardLayoutSetException extends RuntimeException { + public final KeyboardId mKeyboardId; + + public KeyboardLayoutSetException(final Throwable cause, final KeyboardId keyboardId) { + super(cause); + mKeyboardId = keyboardId; + } + } + + private static final class ElementParams { + int mKeyboardXmlId; + boolean mProximityCharsCorrectionEnabled; + boolean mSupportsSplitLayout; + boolean mAllowRedundantMoreKeys; + public ElementParams() {} + } + + public static final class Params { + String mKeyboardLayoutSetName; + int mMode; + boolean mDisableTouchPositionCorrectionDataForTest; + // TODO: Use {@link InputAttributes} instead of these variables. + EditorInfo mEditorInfo; + boolean mIsPasswordField; + boolean mVoiceInputKeyEnabled; + boolean mNoSettingsKey; + boolean mLanguageSwitchKeyEnabled; + RichInputMethodSubtype mSubtype; + boolean mIsSpellChecker; + int mKeyboardWidth; + int mKeyboardHeight; + int mScriptId = ScriptUtils.SCRIPT_LATIN; + // Indicates if the user has enabled the split-layout preference + // and the required ProductionFlags are enabled. + boolean mIsSplitLayoutEnabledByUser; + // Indicates if split layout is actually enabled, taking into account + // whether the user has enabled it, and the keyboard layout supports it. + boolean mIsSplitLayoutEnabled; + // Sparse array of KeyboardLayoutSet element parameters indexed by element's id. + final SparseArray<ElementParams> mKeyboardLayoutSetElementIdToParamsMap = + new SparseArray<>(); + } + + public static void onSystemLocaleChanged() { + clearKeyboardCache(); + } + + public static void onKeyboardThemeChanged() { + clearKeyboardCache(); + } + + private static void clearKeyboardCache() { + sKeyboardCache.clear(); + sUniqueKeysCache.clear(); + } + + public static int getScriptId(final Resources resources, + @Nonnull final InputMethodSubtype subtype) { + final Integer value = sScriptIdsForSubtypes.get(subtype); + if (null == value) { + final int scriptId = Builder.readScriptId(resources, subtype); + sScriptIdsForSubtypes.put(subtype, scriptId); + return scriptId; + } + return value; + } + + KeyboardLayoutSet(final Context context, @Nonnull final Params params) { + mContext = context; + mParams = params; + } + + @Nonnull + public Keyboard getKeyboard(final int baseKeyboardLayoutSetElementId) { + final int keyboardLayoutSetElementId; + switch (mParams.mMode) { + case KeyboardId.MODE_PHONE: + if (baseKeyboardLayoutSetElementId == KeyboardId.ELEMENT_SYMBOLS) { + keyboardLayoutSetElementId = KeyboardId.ELEMENT_PHONE_SYMBOLS; + } else { + keyboardLayoutSetElementId = KeyboardId.ELEMENT_PHONE; + } + break; + case KeyboardId.MODE_NUMBER: + case KeyboardId.MODE_DATE: + case KeyboardId.MODE_TIME: + case KeyboardId.MODE_DATETIME: + keyboardLayoutSetElementId = KeyboardId.ELEMENT_NUMBER; + break; + default: + keyboardLayoutSetElementId = baseKeyboardLayoutSetElementId; + break; + } + + ElementParams elementParams = mParams.mKeyboardLayoutSetElementIdToParamsMap.get( + keyboardLayoutSetElementId); + if (elementParams == null) { + elementParams = mParams.mKeyboardLayoutSetElementIdToParamsMap.get( + KeyboardId.ELEMENT_ALPHABET); + } + // Note: The keyboard for each shift state, and mode are represented as an elementName + // attribute in a keyboard_layout_set XML file. Also each keyboard layout XML resource is + // specified as an elementKeyboard attribute in the file. + // The KeyboardId is an internal key for a Keyboard object. + + mParams.mIsSplitLayoutEnabled = mParams.mIsSplitLayoutEnabledByUser + && elementParams.mSupportsSplitLayout; + final KeyboardId id = new KeyboardId(keyboardLayoutSetElementId, mParams); + try { + return getKeyboard(elementParams, id); + } catch (final RuntimeException e) { + Log.e(TAG, "Can't create keyboard: " + id, e); + throw new KeyboardLayoutSetException(e, id); + } + } + + @Nonnull + private Keyboard getKeyboard(final ElementParams elementParams, final KeyboardId id) { + final SoftReference<Keyboard> ref = sKeyboardCache.get(id); + final Keyboard cachedKeyboard = (ref == null) ? null : ref.get(); + if (cachedKeyboard != null) { + if (DEBUG_CACHE) { + Log.d(TAG, "keyboard cache size=" + sKeyboardCache.size() + ": HIT id=" + id); + } + return cachedKeyboard; + } + + final KeyboardBuilder<KeyboardParams> builder = + new KeyboardBuilder<>(mContext, new KeyboardParams(sUniqueKeysCache)); + sUniqueKeysCache.setEnabled(id.isAlphabetKeyboard()); + builder.setAllowRedundantMoreKes(elementParams.mAllowRedundantMoreKeys); + final int keyboardXmlId = elementParams.mKeyboardXmlId; + builder.load(keyboardXmlId, id); + if (mParams.mDisableTouchPositionCorrectionDataForTest) { + builder.disableTouchPositionCorrectionDataForTest(); + } + builder.setProximityCharsCorrectionEnabled(elementParams.mProximityCharsCorrectionEnabled); + final Keyboard keyboard = builder.build(); + sKeyboardCache.put(id, new SoftReference<>(keyboard)); + if ((id.mElementId == KeyboardId.ELEMENT_ALPHABET + || id.mElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED) + && !mParams.mIsSpellChecker) { + // We only forcibly cache the primary, "ALPHABET", layouts. + for (int i = sForcibleKeyboardCache.length - 1; i >= 1; --i) { + sForcibleKeyboardCache[i] = sForcibleKeyboardCache[i - 1]; + } + sForcibleKeyboardCache[0] = keyboard; + if (DEBUG_CACHE) { + Log.d(TAG, "forcing caching of keyboard with id=" + id); + } + } + if (DEBUG_CACHE) { + Log.d(TAG, "keyboard cache size=" + sKeyboardCache.size() + ": " + + ((ref == null) ? "LOAD" : "GCed") + " id=" + id); + } + return keyboard; + } + + public int getScriptId() { + return mParams.mScriptId; + } + + public static final class Builder { + private final Context mContext; + private final String mPackageName; + private final Resources mResources; + + private final Params mParams = new Params(); + + private static final EditorInfo EMPTY_EDITOR_INFO = new EditorInfo(); + + public Builder(final Context context, @Nullable final EditorInfo ei) { + mContext = context; + mPackageName = context.getPackageName(); + mResources = context.getResources(); + final Params params = mParams; + + final EditorInfo editorInfo = (ei != null) ? ei : EMPTY_EDITOR_INFO; + params.mMode = getKeyboardMode(editorInfo); + // TODO: Consolidate those with {@link InputAttributes}. + params.mEditorInfo = editorInfo; + params.mIsPasswordField = InputTypeUtils.isPasswordInputType(editorInfo.inputType); + params.mNoSettingsKey = InputAttributes.inPrivateImeOptions( + mPackageName, NO_SETTINGS_KEY, editorInfo); + + // When the device is still unlocked, features like showing the IME setting app need to + // be locked down. + // TODO: Switch to {@code UserManagerCompat.isUserUnlocked()} in the support-v4 library + // when it becomes publicly available. + @UserManagerCompatUtils.LockState + final int lockState = UserManagerCompatUtils.getUserLockState(context); + if (lockState == UserManagerCompatUtils.LOCK_STATE_LOCKED) { + params.mNoSettingsKey = true; + } + } + + public Builder setKeyboardGeometry(final int keyboardWidth, final int keyboardHeight) { + mParams.mKeyboardWidth = keyboardWidth; + mParams.mKeyboardHeight = keyboardHeight; + return this; + } + + public Builder setSubtype(@Nonnull final RichInputMethodSubtype subtype) { + final boolean asciiCapable = InputMethodSubtypeCompatUtils.isAsciiCapable(subtype); + // TODO: Consolidate with {@link InputAttributes}. + @SuppressWarnings("deprecation") + final boolean deprecatedForceAscii = InputAttributes.inPrivateImeOptions( + mPackageName, FORCE_ASCII, mParams.mEditorInfo); + final boolean forceAscii = EditorInfoCompatUtils.hasFlagForceAscii( + mParams.mEditorInfo.imeOptions) + || deprecatedForceAscii; + final RichInputMethodSubtype keyboardSubtype = (forceAscii && !asciiCapable) + ? RichInputMethodSubtype.getNoLanguageSubtype() + : subtype; + mParams.mSubtype = keyboardSubtype; + mParams.mKeyboardLayoutSetName = KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX + + keyboardSubtype.getKeyboardLayoutSetName(); + return this; + } + + public Builder setIsSpellChecker(final boolean isSpellChecker) { + mParams.mIsSpellChecker = isSpellChecker; + return this; + } + + public Builder setVoiceInputKeyEnabled(final boolean enabled) { + mParams.mVoiceInputKeyEnabled = enabled; + return this; + } + + public Builder setLanguageSwitchKeyEnabled(final boolean enabled) { + mParams.mLanguageSwitchKeyEnabled = enabled; + return this; + } + + public Builder disableTouchPositionCorrectionData() { + mParams.mDisableTouchPositionCorrectionDataForTest = true; + return this; + } + + public Builder setSplitLayoutEnabledByUser(final boolean enabled) { + mParams.mIsSplitLayoutEnabledByUser = enabled; + return this; + } + + // Super redux version of reading the script ID for some subtype from Xml. + static int readScriptId(final Resources resources, final InputMethodSubtype subtype) { + final String layoutSetName = KEYBOARD_LAYOUT_SET_RESOURCE_PREFIX + + SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype); + final int xmlId = getXmlId(resources, layoutSetName); + final XmlResourceParser parser = resources.getXml(xmlId); + try { + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + // Bovinate through the XML stupidly searching for TAG_FEATURE, and read + // the script Id from it. + parser.next(); + final String tag = parser.getName(); + if (TAG_FEATURE.equals(tag)) { + return readScriptIdFromTagFeature(resources, parser); + } + } + } catch (final IOException | XmlPullParserException e) { + throw new RuntimeException(e.getMessage() + " in " + layoutSetName, e); + } finally { + parser.close(); + } + // If the tag is not found, then the default script is Latin. + return ScriptUtils.SCRIPT_LATIN; + } + + private static int readScriptIdFromTagFeature(final Resources resources, + final XmlPullParser parser) throws IOException, XmlPullParserException { + final TypedArray featureAttr = resources.obtainAttributes(Xml.asAttributeSet(parser), + R.styleable.KeyboardLayoutSet_Feature); + try { + final int scriptId = + featureAttr.getInt(R.styleable.KeyboardLayoutSet_Feature_supportedScript, + ScriptUtils.SCRIPT_UNKNOWN); + XmlParseUtils.checkEndTag(TAG_FEATURE, parser); + return scriptId; + } finally { + featureAttr.recycle(); + } + } + + public KeyboardLayoutSet build() { + if (mParams.mSubtype == null) + throw new RuntimeException("KeyboardLayoutSet subtype is not specified"); + final int xmlId = getXmlId(mResources, mParams.mKeyboardLayoutSetName); + try { + parseKeyboardLayoutSet(mResources, xmlId); + } catch (final IOException | XmlPullParserException e) { + throw new RuntimeException(e.getMessage() + " in " + mParams.mKeyboardLayoutSetName, + e); + } + return new KeyboardLayoutSet(mContext, mParams); + } + + private static int getXmlId(final Resources resources, final String keyboardLayoutSetName) { + final String packageName = resources.getResourcePackageName( + R.xml.keyboard_layout_set_qwerty); + return resources.getIdentifier(keyboardLayoutSetName, "xml", packageName); + } + + private void parseKeyboardLayoutSet(final Resources res, final int resId) + throws XmlPullParserException, IOException { + final XmlResourceParser parser = res.getXml(resId); + try { + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_KEYBOARD_SET.equals(tag)) { + parseKeyboardLayoutSetContent(parser); + } else { + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_KEYBOARD_SET); + } + } + } + } finally { + parser.close(); + } + } + + private void parseKeyboardLayoutSetContent(final XmlPullParser parser) + throws XmlPullParserException, IOException { + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_ELEMENT.equals(tag)) { + parseKeyboardLayoutSetElement(parser); + } else if (TAG_FEATURE.equals(tag)) { + mParams.mScriptId = readScriptIdFromTagFeature(mResources, parser); + } else { + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_KEYBOARD_SET); + } + } else if (event == XmlPullParser.END_TAG) { + final String tag = parser.getName(); + if (TAG_KEYBOARD_SET.equals(tag)) { + break; + } + throw new XmlParseUtils.IllegalEndTag(parser, tag, TAG_KEYBOARD_SET); + } + } + } + + private void parseKeyboardLayoutSetElement(final XmlPullParser parser) + throws XmlPullParserException, IOException { + final TypedArray a = mResources.obtainAttributes(Xml.asAttributeSet(parser), + R.styleable.KeyboardLayoutSet_Element); + try { + XmlParseUtils.checkAttributeExists(a, + R.styleable.KeyboardLayoutSet_Element_elementName, "elementName", + TAG_ELEMENT, parser); + XmlParseUtils.checkAttributeExists(a, + R.styleable.KeyboardLayoutSet_Element_elementKeyboard, "elementKeyboard", + TAG_ELEMENT, parser); + XmlParseUtils.checkEndTag(TAG_ELEMENT, parser); + + final ElementParams elementParams = new ElementParams(); + final int elementName = a.getInt( + R.styleable.KeyboardLayoutSet_Element_elementName, 0); + elementParams.mKeyboardXmlId = a.getResourceId( + R.styleable.KeyboardLayoutSet_Element_elementKeyboard, 0); + elementParams.mProximityCharsCorrectionEnabled = a.getBoolean( + R.styleable.KeyboardLayoutSet_Element_enableProximityCharsCorrection, + false); + elementParams.mSupportsSplitLayout = a.getBoolean( + R.styleable.KeyboardLayoutSet_Element_supportsSplitLayout, false); + elementParams.mAllowRedundantMoreKeys = a.getBoolean( + R.styleable.KeyboardLayoutSet_Element_allowRedundantMoreKeys, true); + mParams.mKeyboardLayoutSetElementIdToParamsMap.put(elementName, elementParams); + } finally { + a.recycle(); + } + } + + private static int getKeyboardMode(final EditorInfo editorInfo) { + final int inputType = editorInfo.inputType; + final int variation = inputType & InputType.TYPE_MASK_VARIATION; + + switch (inputType & InputType.TYPE_MASK_CLASS) { + case InputType.TYPE_CLASS_NUMBER: + return KeyboardId.MODE_NUMBER; + case InputType.TYPE_CLASS_DATETIME: + switch (variation) { + case InputType.TYPE_DATETIME_VARIATION_DATE: + return KeyboardId.MODE_DATE; + case InputType.TYPE_DATETIME_VARIATION_TIME: + return KeyboardId.MODE_TIME; + default: // InputType.TYPE_DATETIME_VARIATION_NORMAL + return KeyboardId.MODE_DATETIME; + } + case InputType.TYPE_CLASS_PHONE: + return KeyboardId.MODE_PHONE; + case InputType.TYPE_CLASS_TEXT: + if (InputTypeUtils.isEmailVariation(variation)) { + return KeyboardId.MODE_EMAIL; + } else if (variation == InputType.TYPE_TEXT_VARIATION_URI) { + return KeyboardId.MODE_URL; + } else if (variation == InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE) { + return KeyboardId.MODE_IM; + } else if (variation == InputType.TYPE_TEXT_VARIATION_FILTER) { + return KeyboardId.MODE_TEXT; + } else { + return KeyboardId.MODE_TEXT; + } + default: + return KeyboardId.MODE_TEXT; + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardSwitcher.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardSwitcher.java new file mode 100644 index 000000000..5b3494aa7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardSwitcher.java @@ -0,0 +1,508 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.keyboard; + +import android.content.Context; +import android.content.res.Resources; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.EditorInfo; + +import androidx.annotation.NonNull; + +import org.kelar.inputmethod.compat.InputMethodServiceCompatUtils; +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.keyboard.KeyboardLayoutSet.KeyboardLayoutSetException; +import org.kelar.inputmethod.keyboard.emoji.EmojiPalettesView; +import org.kelar.inputmethod.keyboard.internal.KeyboardState; +import org.kelar.inputmethod.keyboard.internal.KeyboardTextsSet; +import org.kelar.inputmethod.latin.InputView; +import org.kelar.inputmethod.latin.LatinIME; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.WordComposer; +import org.kelar.inputmethod.latin.define.ProductionFlags; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.settings.SettingsValues; +import org.kelar.inputmethod.latin.utils.CapsModeUtils; +import org.kelar.inputmethod.latin.utils.LanguageOnSpacebarUtils; +import org.kelar.inputmethod.latin.utils.RecapitalizeStatus; +import org.kelar.inputmethod.latin.utils.ResourceUtils; +import org.kelar.inputmethod.latin.utils.ScriptUtils; + +import javax.annotation.Nonnull; + +public final class KeyboardSwitcher implements KeyboardState.SwitchActions { + private static final String TAG = KeyboardSwitcher.class.getSimpleName(); + + private InputView mCurrentInputView; + private View mMainKeyboardFrame; + private MainKeyboardView mKeyboardView; + private EmojiPalettesView mEmojiPalettesView; + private LatinIME mLatinIME; + private RichInputMethodManager mRichImm; + private boolean mIsHardwareAcceleratedDrawingEnabled; + + private KeyboardState mState; + + private KeyboardLayoutSet mKeyboardLayoutSet; + // TODO: The following {@link KeyboardTextsSet} should be in {@link KeyboardLayoutSet}. + private final KeyboardTextsSet mKeyboardTextsSet = new KeyboardTextsSet(); + + private KeyboardTheme mKeyboardTheme; + private Context mThemeContext; + + private static final KeyboardSwitcher sInstance = new KeyboardSwitcher(); + + public static KeyboardSwitcher getInstance() { + return sInstance; + } + + private KeyboardSwitcher() { + // Intentional empty constructor for singleton. + } + + public static void init(final LatinIME latinIme) { + sInstance.initInternal(latinIme); + } + + private void initInternal(final LatinIME latinIme) { + mLatinIME = latinIme; + mRichImm = RichInputMethodManager.getInstance(); + mState = new KeyboardState(this); + mIsHardwareAcceleratedDrawingEnabled = + InputMethodServiceCompatUtils.enableHardwareAcceleration(mLatinIME); + } + + public void updateKeyboardTheme(@NonNull Context displayContext) { + final boolean themeUpdated = updateKeyboardThemeAndContextThemeWrapper( + displayContext, KeyboardTheme.getKeyboardTheme(displayContext /* context */)); + if (themeUpdated && mKeyboardView != null) { + mLatinIME.setInputView( + onCreateInputView(displayContext, mIsHardwareAcceleratedDrawingEnabled)); + } + } + + private boolean updateKeyboardThemeAndContextThemeWrapper(final Context context, + final KeyboardTheme keyboardTheme) { + if (mThemeContext == null || !keyboardTheme.equals(mKeyboardTheme) + || !mThemeContext.getResources().equals(context.getResources())) { + mKeyboardTheme = keyboardTheme; + mThemeContext = new ContextThemeWrapper(context, keyboardTheme.mStyleId); + KeyboardLayoutSet.onKeyboardThemeChanged(); + return true; + } + return false; + } + + public void loadKeyboard(final EditorInfo editorInfo, final SettingsValues settingsValues, + final int currentAutoCapsState, final int currentRecapitalizeState) { + final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder( + mThemeContext, editorInfo); + final Resources res = mThemeContext.getResources(); + final int keyboardWidth = ResourceUtils.getDefaultKeyboardWidth(mThemeContext); + final int keyboardHeight = ResourceUtils.getKeyboardHeight(res, settingsValues); + builder.setKeyboardGeometry(keyboardWidth, keyboardHeight); + builder.setSubtype(mRichImm.getCurrentSubtype()); + builder.setVoiceInputKeyEnabled(settingsValues.mShowsVoiceInputKey); + builder.setLanguageSwitchKeyEnabled(mLatinIME.shouldShowLanguageSwitchKey()); + builder.setSplitLayoutEnabledByUser(ProductionFlags.IS_SPLIT_KEYBOARD_SUPPORTED + && settingsValues.mIsSplitKeyboardEnabled); + mKeyboardLayoutSet = builder.build(); + try { + mState.onLoadKeyboard(currentAutoCapsState, currentRecapitalizeState); + mKeyboardTextsSet.setLocale(mRichImm.getCurrentSubtypeLocale(), mThemeContext); + } catch (KeyboardLayoutSetException e) { + Log.w(TAG, "loading keyboard failed: " + e.mKeyboardId, e.getCause()); + } + } + + public void saveKeyboardState() { + if (getKeyboard() != null || isShowingEmojiPalettes()) { + mState.onSaveKeyboardState(); + } + } + + public void onHideWindow() { + if (mKeyboardView != null) { + mKeyboardView.onHideWindow(); + } + } + + private void setKeyboard( + @Nonnull final int keyboardId, + @Nonnull final KeyboardSwitchState toggleState) { + // Make {@link MainKeyboardView} visible and hide {@link EmojiPalettesView}. + final SettingsValues currentSettingsValues = Settings.getInstance().getCurrent(); + setMainKeyboardFrame(currentSettingsValues, toggleState); + // TODO: pass this object to setKeyboard instead of getting the current values. + final MainKeyboardView keyboardView = mKeyboardView; + final Keyboard oldKeyboard = keyboardView.getKeyboard(); + final Keyboard newKeyboard = mKeyboardLayoutSet.getKeyboard(keyboardId); + keyboardView.setKeyboard(newKeyboard); + mCurrentInputView.setKeyboardTopPadding(newKeyboard.mTopPadding); + keyboardView.setKeyPreviewPopupEnabled( + currentSettingsValues.mKeyPreviewPopupOn, + currentSettingsValues.mKeyPreviewPopupDismissDelay); + keyboardView.setKeyPreviewAnimationParams( + currentSettingsValues.mHasCustomKeyPreviewAnimationParams, + currentSettingsValues.mKeyPreviewShowUpStartXScale, + currentSettingsValues.mKeyPreviewShowUpStartYScale, + currentSettingsValues.mKeyPreviewShowUpDuration, + currentSettingsValues.mKeyPreviewDismissEndXScale, + currentSettingsValues.mKeyPreviewDismissEndYScale, + currentSettingsValues.mKeyPreviewDismissDuration); + keyboardView.updateShortcutKey(mRichImm.isShortcutImeReady()); + final boolean subtypeChanged = (oldKeyboard == null) + || !newKeyboard.mId.mSubtype.equals(oldKeyboard.mId.mSubtype); + final int languageOnSpacebarFormatType = LanguageOnSpacebarUtils + .getLanguageOnSpacebarFormatType(newKeyboard.mId.mSubtype); + final boolean hasMultipleEnabledIMEsOrSubtypes = mRichImm + .hasMultipleEnabledIMEsOrSubtypes(true /* shouldIncludeAuxiliarySubtypes */); + keyboardView.startDisplayLanguageOnSpacebar(subtypeChanged, languageOnSpacebarFormatType, + hasMultipleEnabledIMEsOrSubtypes); + } + + public Keyboard getKeyboard() { + if (mKeyboardView != null) { + return mKeyboardView.getKeyboard(); + } + return null; + } + + // TODO: Remove this method. Come up with a more comprehensive way to reset the keyboard layout + // when a keyboard layout set doesn't get reloaded in LatinIME.onStartInputViewInternal(). + public void resetKeyboardStateToAlphabet(final int currentAutoCapsState, + final int currentRecapitalizeState) { + mState.onResetKeyboardStateToAlphabet(currentAutoCapsState, currentRecapitalizeState); + } + + public void onPressKey(final int code, final boolean isSinglePointer, + final int currentAutoCapsState, final int currentRecapitalizeState) { + mState.onPressKey(code, isSinglePointer, currentAutoCapsState, currentRecapitalizeState); + } + + public void onReleaseKey(final int code, final boolean withSliding, + final int currentAutoCapsState, final int currentRecapitalizeState) { + mState.onReleaseKey(code, withSliding, currentAutoCapsState, currentRecapitalizeState); + } + + public void onFinishSlidingInput(final int currentAutoCapsState, + final int currentRecapitalizeState) { + mState.onFinishSlidingInput(currentAutoCapsState, currentRecapitalizeState); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setAlphabetKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setAlphabetKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_ALPHABET, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setAlphabetManualShiftedKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setAlphabetManualShiftedKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setAlphabetAutomaticShiftedKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setAlphabetAutomaticShiftedKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setAlphabetShiftLockedKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setAlphabetShiftLockedKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setAlphabetShiftLockShiftedKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setAlphabetShiftLockShiftedKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setSymbolsKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setSymbolsKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_SYMBOLS, KeyboardSwitchState.OTHER); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setSymbolsShiftedKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setSymbolsShiftedKeyboard"); + } + setKeyboard(KeyboardId.ELEMENT_SYMBOLS_SHIFTED, KeyboardSwitchState.SYMBOLS_SHIFTED); + } + + public boolean isImeSuppressedByHardwareKeyboard( + @Nonnull final SettingsValues settingsValues, + @Nonnull final KeyboardSwitchState toggleState) { + return settingsValues.mHasHardwareKeyboard && toggleState == KeyboardSwitchState.HIDDEN; + } + + private void setMainKeyboardFrame( + @Nonnull final SettingsValues settingsValues, + @Nonnull final KeyboardSwitchState toggleState) { + final int visibility = isImeSuppressedByHardwareKeyboard(settingsValues, toggleState) + ? View.GONE : View.VISIBLE; + mKeyboardView.setVisibility(visibility); + // The visibility of {@link #mKeyboardView} must be aligned with {@link #MainKeyboardFrame}. + // @see #getVisibleKeyboardView() and + // @see LatinIME#onComputeInset(android.inputmethodservice.InputMethodService.Insets) + mMainKeyboardFrame.setVisibility(visibility); + mEmojiPalettesView.setVisibility(View.GONE); + mEmojiPalettesView.stopEmojiPalettes(); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void setEmojiKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setEmojiKeyboard"); + } + final Keyboard keyboard = mKeyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET); + mMainKeyboardFrame.setVisibility(View.GONE); + // The visibility of {@link #mKeyboardView} must be aligned with {@link #MainKeyboardFrame}. + // @see #getVisibleKeyboardView() and + // @see LatinIME#onComputeInset(android.inputmethodservice.InputMethodService.Insets) + mKeyboardView.setVisibility(View.GONE); + mEmojiPalettesView.startEmojiPalettes( + mKeyboardTextsSet.getText(KeyboardTextsSet.SWITCH_TO_ALPHA_KEY_LABEL), + mKeyboardView.getKeyVisualAttribute(), keyboard.mIconsSet); + mEmojiPalettesView.setVisibility(View.VISIBLE); + } + + public enum KeyboardSwitchState { + HIDDEN(-1), + SYMBOLS_SHIFTED(KeyboardId.ELEMENT_SYMBOLS_SHIFTED), + EMOJI(KeyboardId.ELEMENT_EMOJI_RECENTS), + OTHER(-1); + + final int mKeyboardId; + + KeyboardSwitchState(int keyboardId) { + mKeyboardId = keyboardId; + } + } + + public KeyboardSwitchState getKeyboardSwitchState() { + boolean hidden = !isShowingEmojiPalettes() + && (mKeyboardLayoutSet == null + || mKeyboardView == null + || !mKeyboardView.isShown()); + KeyboardSwitchState state; + if (hidden) { + return KeyboardSwitchState.HIDDEN; + } else if (isShowingEmojiPalettes()) { + return KeyboardSwitchState.EMOJI; + } else if (isShowingKeyboardId(KeyboardId.ELEMENT_SYMBOLS_SHIFTED)) { + return KeyboardSwitchState.SYMBOLS_SHIFTED; + } + return KeyboardSwitchState.OTHER; + } + + public void onToggleKeyboard(@Nonnull final KeyboardSwitchState toggleState) { + KeyboardSwitchState currentState = getKeyboardSwitchState(); + Log.w(TAG, "onToggleKeyboard() : Current = " + currentState + " : Toggle = " + toggleState); + if (currentState == toggleState) { + mLatinIME.stopShowingInputView(); + mLatinIME.hideWindow(); + setAlphabetKeyboard(); + } else { + mLatinIME.startShowingInputView(true); + if (toggleState == KeyboardSwitchState.EMOJI) { + setEmojiKeyboard(); + } else { + mEmojiPalettesView.stopEmojiPalettes(); + mEmojiPalettesView.setVisibility(View.GONE); + + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.VISIBLE); + setKeyboard(toggleState.mKeyboardId, toggleState); + } + } + } + + // Future method for requesting an updating to the shift state. + @Override + public void requestUpdatingShiftState(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_ACTION) { + Log.d(TAG, "requestUpdatingShiftState: " + + " autoCapsFlags=" + CapsModeUtils.flagsToString(autoCapsFlags) + + " recapitalizeMode=" + RecapitalizeStatus.modeToString(recapitalizeMode)); + } + mState.onUpdateShiftState(autoCapsFlags, recapitalizeMode); + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void startDoubleTapShiftKeyTimer() { + if (DEBUG_TIMER_ACTION) { + Log.d(TAG, "startDoubleTapShiftKeyTimer"); + } + final MainKeyboardView keyboardView = getMainKeyboardView(); + if (keyboardView != null) { + keyboardView.startDoubleTapShiftKeyTimer(); + } + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public void cancelDoubleTapShiftKeyTimer() { + if (DEBUG_TIMER_ACTION) { + Log.d(TAG, "setAlphabetKeyboard"); + } + final MainKeyboardView keyboardView = getMainKeyboardView(); + if (keyboardView != null) { + keyboardView.cancelDoubleTapShiftKeyTimer(); + } + } + + // Implements {@link KeyboardState.SwitchActions}. + @Override + public boolean isInDoubleTapShiftKeyTimeout() { + if (DEBUG_TIMER_ACTION) { + Log.d(TAG, "isInDoubleTapShiftKeyTimeout"); + } + final MainKeyboardView keyboardView = getMainKeyboardView(); + return keyboardView != null && keyboardView.isInDoubleTapShiftKeyTimeout(); + } + + /** + * Updates state machine to figure out when to automatically switch back to the previous mode. + */ + public void onEvent(final Event event, final int currentAutoCapsState, + final int currentRecapitalizeState) { + mState.onEvent(event, currentAutoCapsState, currentRecapitalizeState); + } + + public boolean isShowingKeyboardId(@Nonnull int... keyboardIds) { + if (mKeyboardView == null || !mKeyboardView.isShown()) { + return false; + } + int activeKeyboardId = mKeyboardView.getKeyboard().mId.mElementId; + for (int keyboardId : keyboardIds) { + if (activeKeyboardId == keyboardId) { + return true; + } + } + return false; + } + + public boolean isShowingEmojiPalettes() { + return mEmojiPalettesView != null && mEmojiPalettesView.isShown(); + } + + public boolean isShowingMoreKeysPanel() { + if (isShowingEmojiPalettes()) { + return false; + } + return mKeyboardView.isShowingMoreKeysPanel(); + } + + public View getVisibleKeyboardView() { + if (isShowingEmojiPalettes()) { + return mEmojiPalettesView; + } + return mKeyboardView; + } + + public MainKeyboardView getMainKeyboardView() { + return mKeyboardView; + } + + public void deallocateMemory() { + if (mKeyboardView != null) { + mKeyboardView.cancelAllOngoingEvents(); + mKeyboardView.deallocateMemory(); + } + if (mEmojiPalettesView != null) { + mEmojiPalettesView.stopEmojiPalettes(); + } + } + + public View onCreateInputView(@NonNull Context displayContext, + final boolean isHardwareAcceleratedDrawingEnabled) { + if (mKeyboardView != null) { + mKeyboardView.closing(); + } + + updateKeyboardThemeAndContextThemeWrapper( + displayContext, KeyboardTheme.getKeyboardTheme(displayContext /* context */)); + mCurrentInputView = (InputView)LayoutInflater.from(mThemeContext).inflate( + R.layout.input_view, null); + mMainKeyboardFrame = mCurrentInputView.findViewById(R.id.main_keyboard_frame); + mEmojiPalettesView = (EmojiPalettesView)mCurrentInputView.findViewById( + R.id.emoji_palettes_view); + + mKeyboardView = (MainKeyboardView) mCurrentInputView.findViewById(R.id.keyboard_view); + mKeyboardView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); + mKeyboardView.setKeyboardActionListener(mLatinIME); + mEmojiPalettesView.setHardwareAcceleratedDrawingEnabled( + isHardwareAcceleratedDrawingEnabled); + mEmojiPalettesView.setKeyboardActionListener(mLatinIME); + return mCurrentInputView; + } + + public int getKeyboardShiftMode() { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return WordComposer.CAPS_MODE_OFF; + } + switch (keyboard.mId.mElementId) { + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED: + case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED: + return WordComposer.CAPS_MODE_MANUAL_SHIFT_LOCKED; + case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED: + return WordComposer.CAPS_MODE_MANUAL_SHIFTED; + case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED: + return WordComposer.CAPS_MODE_AUTO_SHIFTED; + default: + return WordComposer.CAPS_MODE_OFF; + } + } + + public int getCurrentKeyboardScriptId() { + if (null == mKeyboardLayoutSet) { + return ScriptUtils.SCRIPT_UNKNOWN; + } + return mKeyboardLayoutSet.getScriptId(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardTheme.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardTheme.java new file mode 100644 index 000000000..e3a14fc25 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardTheme.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.latin.R; + +import java.util.ArrayList; +import java.util.Arrays; + +public final class KeyboardTheme implements Comparable<KeyboardTheme> { + private static final String TAG = KeyboardTheme.class.getSimpleName(); + + static final String KLP_KEYBOARD_THEME_KEY = "pref_keyboard_layout_20110916"; + static final String LXX_KEYBOARD_THEME_KEY = "pref_keyboard_theme_20140509"; + + // These should be aligned with Keyboard.themeId and Keyboard.Case.keyboardTheme + // attributes' values in attrs.xml. + public static final int THEME_ID_ICS = 0; + public static final int THEME_ID_KLP = 2; + public static final int THEME_ID_LXX_LIGHT = 3; + public static final int THEME_ID_LXX_DARK = 4; + public static final int DEFAULT_THEME_ID = THEME_ID_KLP; + + private static KeyboardTheme[] AVAILABLE_KEYBOARD_THEMES; + + /* package private for testing */ + static final KeyboardTheme[] KEYBOARD_THEMES = { + new KeyboardTheme(THEME_ID_ICS, "ICS", R.style.KeyboardTheme_ICS, + // This has never been selected because we support ICS or later. + VERSION_CODES.BASE), + new KeyboardTheme(THEME_ID_KLP, "KLP", R.style.KeyboardTheme_KLP, + // Default theme for ICS, JB, and KLP. + VERSION_CODES.ICE_CREAM_SANDWICH), + new KeyboardTheme(THEME_ID_LXX_LIGHT, "LXXLight", R.style.KeyboardTheme_LXX_Light, + // Default theme for LXX. + Build.VERSION_CODES.LOLLIPOP), + new KeyboardTheme(THEME_ID_LXX_DARK, "LXXDark", R.style.KeyboardTheme_LXX_Dark, + // This has never been selected as default theme. + VERSION_CODES.BASE), + }; + + static { + // Sort {@link #KEYBOARD_THEME} by descending order of {@link #mMinApiVersion}. + Arrays.sort(KEYBOARD_THEMES); + } + + public final int mThemeId; + public final int mStyleId; + public final String mThemeName; + public final int mMinApiVersion; + + // Note: The themeId should be aligned with "themeId" attribute of Keyboard style + // in values/themes-<style>.xml. + private KeyboardTheme(final int themeId, final String themeName, final int styleId, + final int minApiVersion) { + mThemeId = themeId; + mThemeName = themeName; + mStyleId = styleId; + mMinApiVersion = minApiVersion; + } + + @Override + public int compareTo(final KeyboardTheme rhs) { + if (mMinApiVersion > rhs.mMinApiVersion) return -1; + if (mMinApiVersion < rhs.mMinApiVersion) return 1; + return 0; + } + + @Override + public boolean equals(final Object o) { + if (o == this) return true; + return (o instanceof KeyboardTheme) && ((KeyboardTheme)o).mThemeId == mThemeId; + } + + @Override + public int hashCode() { + return mThemeId; + } + + /* package private for testing */ + static KeyboardTheme searchKeyboardThemeById(final int themeId, + final KeyboardTheme[] availableThemeIds) { + // TODO: This search algorithm isn't optimal if there are many themes. + for (final KeyboardTheme theme : availableThemeIds) { + if (theme.mThemeId == themeId) { + return theme; + } + } + return null; + } + + /* package private for testing */ + static KeyboardTheme getDefaultKeyboardTheme(final SharedPreferences prefs, + final int sdkVersion, final KeyboardTheme[] availableThemeArray) { + final String klpThemeIdString = prefs.getString(KLP_KEYBOARD_THEME_KEY, null); + if (klpThemeIdString != null) { + if (sdkVersion <= VERSION_CODES.KITKAT) { + try { + final int themeId = Integer.parseInt(klpThemeIdString); + final KeyboardTheme theme = searchKeyboardThemeById(themeId, + availableThemeArray); + if (theme != null) { + return theme; + } + Log.w(TAG, "Unknown keyboard theme in KLP preference: " + klpThemeIdString); + } catch (final NumberFormatException e) { + Log.w(TAG, "Illegal keyboard theme in KLP preference: " + klpThemeIdString, e); + } + } + // Remove old preference. + Log.i(TAG, "Remove KLP keyboard theme preference: " + klpThemeIdString); + prefs.edit().remove(KLP_KEYBOARD_THEME_KEY).apply(); + } + // TODO: This search algorithm isn't optimal if there are many themes. + for (final KeyboardTheme theme : availableThemeArray) { + if (sdkVersion >= theme.mMinApiVersion) { + return theme; + } + } + return searchKeyboardThemeById(DEFAULT_THEME_ID, availableThemeArray); + } + + public static String getKeyboardThemeName(final int themeId) { + final KeyboardTheme theme = searchKeyboardThemeById(themeId, KEYBOARD_THEMES); + return theme.mThemeName; + } + + public static void saveKeyboardThemeId(final int themeId, final SharedPreferences prefs) { + saveKeyboardThemeId(themeId, prefs, BuildCompatUtils.EFFECTIVE_SDK_INT); + } + + /* package private for testing */ + static String getPreferenceKey(final int sdkVersion) { + if (sdkVersion <= VERSION_CODES.KITKAT) { + return KLP_KEYBOARD_THEME_KEY; + } + return LXX_KEYBOARD_THEME_KEY; + } + + /* package private for testing */ + static void saveKeyboardThemeId(final int themeId, final SharedPreferences prefs, + final int sdkVersion) { + final String prefKey = getPreferenceKey(sdkVersion); + prefs.edit().putString(prefKey, Integer.toString(themeId)).apply(); + } + + public static KeyboardTheme getKeyboardTheme(final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final KeyboardTheme[] availableThemeArray = getAvailableThemeArray(context); + return getKeyboardTheme(prefs, BuildCompatUtils.EFFECTIVE_SDK_INT, availableThemeArray); + } + + /* package private for testing */ + static KeyboardTheme[] getAvailableThemeArray(final Context context) { + if (AVAILABLE_KEYBOARD_THEMES == null) { + final int[] availableThemeIdStringArray = context.getResources().getIntArray( + R.array.keyboard_theme_ids); + final ArrayList<KeyboardTheme> availableThemeList = new ArrayList<>(); + for (final int id : availableThemeIdStringArray) { + final KeyboardTheme theme = searchKeyboardThemeById(id, KEYBOARD_THEMES); + if (theme != null) { + availableThemeList.add(theme); + } + } + AVAILABLE_KEYBOARD_THEMES = availableThemeList.toArray( + new KeyboardTheme[availableThemeList.size()]); + Arrays.sort(AVAILABLE_KEYBOARD_THEMES); + } + return AVAILABLE_KEYBOARD_THEMES; + } + + /* package private for testing */ + static KeyboardTheme getKeyboardTheme(final SharedPreferences prefs, final int sdkVersion, + final KeyboardTheme[] availableThemeArray) { + final String lxxThemeIdString = prefs.getString(LXX_KEYBOARD_THEME_KEY, null); + if (lxxThemeIdString == null) { + return getDefaultKeyboardTheme(prefs, sdkVersion, availableThemeArray); + } + try { + final int themeId = Integer.parseInt(lxxThemeIdString); + final KeyboardTheme theme = searchKeyboardThemeById(themeId, availableThemeArray); + if (theme != null) { + return theme; + } + Log.w(TAG, "Unknown keyboard theme in LXX preference: " + lxxThemeIdString); + } catch (final NumberFormatException e) { + Log.w(TAG, "Illegal keyboard theme in LXX preference: " + lxxThemeIdString, e); + } + // Remove preference that contains unknown or illegal theme id. + prefs.edit().remove(LXX_KEYBOARD_THEME_KEY).apply(); + return getDefaultKeyboardTheme(prefs, sdkVersion, availableThemeArray); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/KeyboardView.java b/java/src/org/kelar/inputmethod/keyboard/KeyboardView.java new file mode 100644 index 000000000..a81e1cb9e --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/KeyboardView.java @@ -0,0 +1,590 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.NinePatchDrawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import org.kelar.inputmethod.keyboard.internal.KeyDrawParams; +import org.kelar.inputmethod.keyboard.internal.KeyVisualAttributes; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.TypefaceUtils; + +import java.util.HashSet; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A view that renders a virtual {@link Keyboard}. + * + * @attr ref android.R.styleable#KeyboardView_keyBackground + * @attr ref android.R.styleable#KeyboardView_functionalKeyBackground + * @attr ref android.R.styleable#KeyboardView_spacebarBackground + * @attr ref android.R.styleable#KeyboardView_spacebarIconWidthRatio + * @attr ref android.R.styleable#Keyboard_Key_keyLabelFlags + * @attr ref android.R.styleable#KeyboardView_keyHintLetterPadding + * @attr ref android.R.styleable#KeyboardView_keyPopupHintLetter + * @attr ref android.R.styleable#KeyboardView_keyPopupHintLetterPadding + * @attr ref android.R.styleable#KeyboardView_keyShiftedLetterHintPadding + * @attr ref android.R.styleable#KeyboardView_keyTextShadowRadius + * @attr ref android.R.styleable#KeyboardView_verticalCorrection + * @attr ref android.R.styleable#Keyboard_Key_keyTypeface + * @attr ref android.R.styleable#Keyboard_Key_keyLetterSize + * @attr ref android.R.styleable#Keyboard_Key_keyLabelSize + * @attr ref android.R.styleable#Keyboard_Key_keyLargeLetterRatio + * @attr ref android.R.styleable#Keyboard_Key_keyLargeLabelRatio + * @attr ref android.R.styleable#Keyboard_Key_keyHintLetterRatio + * @attr ref android.R.styleable#Keyboard_Key_keyShiftedLetterHintRatio + * @attr ref android.R.styleable#Keyboard_Key_keyHintLabelRatio + * @attr ref android.R.styleable#Keyboard_Key_keyLabelOffCenterRatio + * @attr ref android.R.styleable#Keyboard_Key_keyHintLabelOffCenterRatio + * @attr ref android.R.styleable#Keyboard_Key_keyPreviewTextRatio + * @attr ref android.R.styleable#Keyboard_Key_keyTextColor + * @attr ref android.R.styleable#Keyboard_Key_keyTextColorDisabled + * @attr ref android.R.styleable#Keyboard_Key_keyTextShadowColor + * @attr ref android.R.styleable#Keyboard_Key_keyHintLetterColor + * @attr ref android.R.styleable#Keyboard_Key_keyHintLabelColor + * @attr ref android.R.styleable#Keyboard_Key_keyShiftedLetterHintInactivatedColor + * @attr ref android.R.styleable#Keyboard_Key_keyShiftedLetterHintActivatedColor + * @attr ref android.R.styleable#Keyboard_Key_keyPreviewTextColor + */ +public class KeyboardView extends View { + // XML attributes + private final KeyVisualAttributes mKeyVisualAttributes; + // Default keyLabelFlags from {@link KeyboardTheme}. + // Currently only "alignHintLabelToBottom" is supported. + private final int mDefaultKeyLabelFlags; + private final float mKeyHintLetterPadding; + private final String mKeyPopupHintLetter; + private final float mKeyPopupHintLetterPadding; + private final float mKeyShiftedLetterHintPadding; + private final float mKeyTextShadowRadius; + private final float mVerticalCorrection; + private final Drawable mKeyBackground; + private final Drawable mFunctionalKeyBackground; + private final Drawable mSpacebarBackground; + private final float mSpacebarIconWidthRatio; + private final Rect mKeyBackgroundPadding = new Rect(); + private static final float KET_TEXT_SHADOW_RADIUS_DISABLED = -1.0f; + + // The maximum key label width in the proportion to the key width. + private static final float MAX_LABEL_RATIO = 0.90f; + + // Main keyboard + // TODO: Consider having a base keyboard object to make this @Nonnull + @Nullable + private Keyboard mKeyboard; + @Nonnull + private final KeyDrawParams mKeyDrawParams = new KeyDrawParams(); + + // Drawing + /** True if all keys should be drawn */ + private boolean mInvalidateAllKeys; + /** The keys that should be drawn */ + private final HashSet<Key> mInvalidatedKeys = new HashSet<>(); + /** The working rectangle for clipping */ + private final Rect mClipRect = new Rect(); + /** The keyboard bitmap buffer for faster updates */ + private Bitmap mOffscreenBuffer; + /** The canvas for the above mutable keyboard bitmap */ + @Nonnull + private final Canvas mOffscreenCanvas = new Canvas(); + @Nonnull + private final Paint mPaint = new Paint(); + private final Paint.FontMetrics mFontMetrics = new Paint.FontMetrics(); + + public KeyboardView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.keyboardViewStyle); + } + + public KeyboardView(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + final TypedArray keyboardViewAttr = context.obtainStyledAttributes(attrs, + R.styleable.KeyboardView, defStyle, R.style.KeyboardView); + mKeyBackground = keyboardViewAttr.getDrawable(R.styleable.KeyboardView_keyBackground); + mKeyBackground.getPadding(mKeyBackgroundPadding); + final Drawable functionalKeyBackground = keyboardViewAttr.getDrawable( + R.styleable.KeyboardView_functionalKeyBackground); + mFunctionalKeyBackground = (functionalKeyBackground != null) ? functionalKeyBackground + : mKeyBackground; + final Drawable spacebarBackground = keyboardViewAttr.getDrawable( + R.styleable.KeyboardView_spacebarBackground); + mSpacebarBackground = (spacebarBackground != null) ? spacebarBackground : mKeyBackground; + mSpacebarIconWidthRatio = keyboardViewAttr.getFloat( + R.styleable.KeyboardView_spacebarIconWidthRatio, 1.0f); + mKeyHintLetterPadding = keyboardViewAttr.getDimension( + R.styleable.KeyboardView_keyHintLetterPadding, 0.0f); + mKeyPopupHintLetter = keyboardViewAttr.getString( + R.styleable.KeyboardView_keyPopupHintLetter); + mKeyPopupHintLetterPadding = keyboardViewAttr.getDimension( + R.styleable.KeyboardView_keyPopupHintLetterPadding, 0.0f); + mKeyShiftedLetterHintPadding = keyboardViewAttr.getDimension( + R.styleable.KeyboardView_keyShiftedLetterHintPadding, 0.0f); + mKeyTextShadowRadius = keyboardViewAttr.getFloat( + R.styleable.KeyboardView_keyTextShadowRadius, KET_TEXT_SHADOW_RADIUS_DISABLED); + mVerticalCorrection = keyboardViewAttr.getDimension( + R.styleable.KeyboardView_verticalCorrection, 0.0f); + keyboardViewAttr.recycle(); + + final TypedArray keyAttr = context.obtainStyledAttributes(attrs, + R.styleable.Keyboard_Key, defStyle, R.style.KeyboardView); + mDefaultKeyLabelFlags = keyAttr.getInt(R.styleable.Keyboard_Key_keyLabelFlags, 0); + mKeyVisualAttributes = KeyVisualAttributes.newInstance(keyAttr); + keyAttr.recycle(); + + mPaint.setAntiAlias(true); + } + + @Nullable + public KeyVisualAttributes getKeyVisualAttribute() { + return mKeyVisualAttributes; + } + + private static void blendAlpha(@Nonnull final Paint paint, final int alpha) { + final int color = paint.getColor(); + paint.setARGB((paint.getAlpha() * alpha) / Constants.Color.ALPHA_OPAQUE, + Color.red(color), Color.green(color), Color.blue(color)); + } + + public void setHardwareAcceleratedDrawingEnabled(final boolean enabled) { + if (!enabled) return; + // TODO: Should use LAYER_TYPE_SOFTWARE when hardware acceleration is off? + setLayerType(LAYER_TYPE_HARDWARE, null); + } + + /** + * Attaches a keyboard to this view. The keyboard can be switched at any time and the + * view will re-layout itself to accommodate the keyboard. + * @see Keyboard + * @see #getKeyboard() + * @param keyboard the keyboard to display in this view + */ + public void setKeyboard(@Nonnull final Keyboard keyboard) { + mKeyboard = keyboard; + final int keyHeight = keyboard.mMostCommonKeyHeight - keyboard.mVerticalGap; + mKeyDrawParams.updateParams(keyHeight, mKeyVisualAttributes); + mKeyDrawParams.updateParams(keyHeight, keyboard.mKeyVisualAttributes); + invalidateAllKeys(); + requestLayout(); + } + + /** + * Returns the current keyboard being displayed by this view. + * @return the currently attached keyboard + * @see #setKeyboard(Keyboard) + */ + @Nullable + public Keyboard getKeyboard() { + return mKeyboard; + } + + protected float getVerticalCorrection() { + return mVerticalCorrection; + } + + @Nonnull + protected KeyDrawParams getKeyDrawParams() { + return mKeyDrawParams; + } + + protected void updateKeyDrawParams(final int keyHeight) { + mKeyDrawParams.updateParams(keyHeight, mKeyVisualAttributes); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + // The main keyboard expands to the entire this {@link KeyboardView}. + final int width = keyboard.mOccupiedWidth + getPaddingLeft() + getPaddingRight(); + final int height = keyboard.mOccupiedHeight + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension(width, height); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + if (canvas.isHardwareAccelerated()) { + onDrawKeyboard(canvas); + return; + } + + final boolean bufferNeedsUpdates = mInvalidateAllKeys || !mInvalidatedKeys.isEmpty(); + if (bufferNeedsUpdates || mOffscreenBuffer == null) { + if (maybeAllocateOffscreenBuffer()) { + mInvalidateAllKeys = true; + // TODO: Stop using the offscreen canvas even when in software rendering + mOffscreenCanvas.setBitmap(mOffscreenBuffer); + } + onDrawKeyboard(mOffscreenCanvas); + } + canvas.drawBitmap(mOffscreenBuffer, 0.0f, 0.0f, null); + } + + private boolean maybeAllocateOffscreenBuffer() { + final int width = getWidth(); + final int height = getHeight(); + if (width == 0 || height == 0) { + return false; + } + if (mOffscreenBuffer != null && mOffscreenBuffer.getWidth() == width + && mOffscreenBuffer.getHeight() == height) { + return false; + } + freeOffscreenBuffer(); + mOffscreenBuffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + return true; + } + + private void freeOffscreenBuffer() { + mOffscreenCanvas.setBitmap(null); + mOffscreenCanvas.setMatrix(null); + if (mOffscreenBuffer != null) { + mOffscreenBuffer.recycle(); + mOffscreenBuffer = null; + } + } + + private void onDrawKeyboard(@Nonnull final Canvas canvas) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return; + } + + final Paint paint = mPaint; + final Drawable background = getBackground(); + // Calculate clip region and set. + final boolean drawAllKeys = mInvalidateAllKeys || mInvalidatedKeys.isEmpty(); + final boolean isHardwareAccelerated = canvas.isHardwareAccelerated(); + // TODO: Confirm if it's really required to draw all keys when hardware acceleration is on. + if (drawAllKeys || isHardwareAccelerated) { + if (!isHardwareAccelerated && background != null) { + // Need to draw keyboard background on {@link #mOffscreenBuffer}. + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR); + background.draw(canvas); + } + // Draw all keys. + for (final Key key : keyboard.getSortedKeys()) { + onDrawKey(key, canvas, paint); + } + } else { + for (final Key key : mInvalidatedKeys) { + if (!keyboard.hasKey(key)) { + continue; + } + if (background != null) { + // Need to redraw key's background on {@link #mOffscreenBuffer}. + final int x = key.getX() + getPaddingLeft(); + final int y = key.getY() + getPaddingTop(); + mClipRect.set(x, y, x + key.getWidth(), y + key.getHeight()); + canvas.save(); + canvas.clipRect(mClipRect); + canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR); + background.draw(canvas); + canvas.restore(); + } + onDrawKey(key, canvas, paint); + } + } + + mInvalidatedKeys.clear(); + mInvalidateAllKeys = false; + } + + private void onDrawKey(@Nonnull final Key key, @Nonnull final Canvas canvas, + @Nonnull final Paint paint) { + final int keyDrawX = key.getDrawX() + getPaddingLeft(); + final int keyDrawY = key.getY() + getPaddingTop(); + canvas.translate(keyDrawX, keyDrawY); + + final KeyVisualAttributes attr = key.getVisualAttributes(); + final KeyDrawParams params = mKeyDrawParams.mayCloneAndUpdateParams(key.getHeight(), attr); + params.mAnimAlpha = Constants.Color.ALPHA_OPAQUE; + + if (!key.isSpacer()) { + final Drawable background = key.selectBackgroundDrawable( + mKeyBackground, mFunctionalKeyBackground, mSpacebarBackground); + if (background != null) { + onDrawKeyBackground(key, canvas, background); + } + } + onDrawKeyTopVisuals(key, canvas, paint, params); + + canvas.translate(-keyDrawX, -keyDrawY); + } + + // Draw key background. + protected void onDrawKeyBackground(@Nonnull final Key key, @Nonnull final Canvas canvas, + @Nonnull final Drawable background) { + final int keyWidth = key.getDrawWidth(); + final int keyHeight = key.getHeight(); + final int bgWidth, bgHeight, bgX, bgY; + if (key.needsToKeepBackgroundAspectRatio(mDefaultKeyLabelFlags) + // HACK: To disable expanding normal/functional key background. + && !key.hasCustomActionLabel()) { + final int intrinsicWidth = background.getIntrinsicWidth(); + final int intrinsicHeight = background.getIntrinsicHeight(); + final float minScale = Math.min( + keyWidth / (float)intrinsicWidth, keyHeight / (float)intrinsicHeight); + bgWidth = (int)(intrinsicWidth * minScale); + bgHeight = (int)(intrinsicHeight * minScale); + bgX = (keyWidth - bgWidth) / 2; + bgY = (keyHeight - bgHeight) / 2; + } else { + final Rect padding = mKeyBackgroundPadding; + bgWidth = keyWidth + padding.left + padding.right; + bgHeight = keyHeight + padding.top + padding.bottom; + bgX = -padding.left; + bgY = -padding.top; + } + final Rect bounds = background.getBounds(); + if (bgWidth != bounds.right || bgHeight != bounds.bottom) { + background.setBounds(0, 0, bgWidth, bgHeight); + } + canvas.translate(bgX, bgY); + background.draw(canvas); + canvas.translate(-bgX, -bgY); + } + + // Draw key top visuals. + protected void onDrawKeyTopVisuals(@Nonnull final Key key, @Nonnull final Canvas canvas, + @Nonnull final Paint paint, @Nonnull final KeyDrawParams params) { + final int keyWidth = key.getDrawWidth(); + final int keyHeight = key.getHeight(); + final float centerX = keyWidth * 0.5f; + final float centerY = keyHeight * 0.5f; + + // Draw key label. + final Keyboard keyboard = getKeyboard(); + final Drawable icon = (keyboard == null) ? null + : key.getIcon(keyboard.mIconsSet, params.mAnimAlpha); + float labelX = centerX; + float labelBaseline = centerY; + final String label = key.getLabel(); + if (label != null) { + paint.setTypeface(key.selectTypeface(params)); + paint.setTextSize(key.selectTextSize(params)); + final float labelCharHeight = TypefaceUtils.getReferenceCharHeight(paint); + final float labelCharWidth = TypefaceUtils.getReferenceCharWidth(paint); + + // Vertical label text alignment. + labelBaseline = centerY + labelCharHeight / 2.0f; + + // Horizontal label text alignment + if (key.isAlignLabelOffCenter()) { + // The label is placed off center of the key. Used mainly on "phone number" layout. + labelX = centerX + params.mLabelOffCenterRatio * labelCharWidth; + paint.setTextAlign(Align.LEFT); + } else { + labelX = centerX; + paint.setTextAlign(Align.CENTER); + } + if (key.needsAutoXScale()) { + final float ratio = Math.min(1.0f, (keyWidth * MAX_LABEL_RATIO) / + TypefaceUtils.getStringWidth(label, paint)); + if (key.needsAutoScale()) { + final float autoSize = paint.getTextSize() * ratio; + paint.setTextSize(autoSize); + } else { + paint.setTextScaleX(ratio); + } + } + + if (key.isEnabled()) { + paint.setColor(key.selectTextColor(params)); + // Set a drop shadow for the text if the shadow radius is positive value. + if (mKeyTextShadowRadius > 0.0f) { + paint.setShadowLayer(mKeyTextShadowRadius, 0.0f, 0.0f, params.mTextShadowColor); + } else { + paint.clearShadowLayer(); + } + } else { + // Make label invisible + paint.setColor(Color.TRANSPARENT); + paint.clearShadowLayer(); + } + blendAlpha(paint, params.mAnimAlpha); + canvas.drawText(label, 0, label.length(), labelX, labelBaseline, paint); + // Turn off drop shadow and reset x-scale. + paint.clearShadowLayer(); + paint.setTextScaleX(1.0f); + } + + // Draw hint label. + final String hintLabel = key.getHintLabel(); + if (hintLabel != null) { + paint.setTextSize(key.selectHintTextSize(params)); + paint.setColor(key.selectHintTextColor(params)); + // TODO: Should add a way to specify type face for hint letters + paint.setTypeface(Typeface.DEFAULT_BOLD); + blendAlpha(paint, params.mAnimAlpha); + final float labelCharHeight = TypefaceUtils.getReferenceCharHeight(paint); + final float labelCharWidth = TypefaceUtils.getReferenceCharWidth(paint); + final float hintX, hintBaseline; + if (key.hasHintLabel()) { + // The hint label is placed just right of the key label. Used mainly on + // "phone number" layout. + hintX = labelX + params.mHintLabelOffCenterRatio * labelCharWidth; + if (key.isAlignHintLabelToBottom(mDefaultKeyLabelFlags)) { + hintBaseline = labelBaseline; + } else { + hintBaseline = centerY + labelCharHeight / 2.0f; + } + paint.setTextAlign(Align.LEFT); + } else if (key.hasShiftedLetterHint()) { + // The hint label is placed at top-right corner of the key. Used mainly on tablet. + hintX = keyWidth - mKeyShiftedLetterHintPadding - labelCharWidth / 2.0f; + paint.getFontMetrics(mFontMetrics); + hintBaseline = -mFontMetrics.top; + paint.setTextAlign(Align.CENTER); + } else { // key.hasHintLetter() + // The hint letter is placed at top-right corner of the key. Used mainly on phone. + final float hintDigitWidth = TypefaceUtils.getReferenceDigitWidth(paint); + final float hintLabelWidth = TypefaceUtils.getStringWidth(hintLabel, paint); + hintX = keyWidth - mKeyHintLetterPadding + - Math.max(hintDigitWidth, hintLabelWidth) / 2.0f; + hintBaseline = -paint.ascent(); + paint.setTextAlign(Align.CENTER); + } + final float adjustmentY = params.mHintLabelVerticalAdjustment * labelCharHeight; + canvas.drawText( + hintLabel, 0, hintLabel.length(), hintX, hintBaseline + adjustmentY, paint); + } + + // Draw key icon. + if (label == null && icon != null) { + final int iconWidth; + if (key.getCode() == Constants.CODE_SPACE && icon instanceof NinePatchDrawable) { + iconWidth = (int)(keyWidth * mSpacebarIconWidthRatio); + } else { + iconWidth = Math.min(icon.getIntrinsicWidth(), keyWidth); + } + final int iconHeight = icon.getIntrinsicHeight(); + final int iconY; + if (key.isAlignIconToBottom()) { + iconY = keyHeight - iconHeight; + } else { + iconY = (keyHeight - iconHeight) / 2; // Align vertically center. + } + final int iconX = (keyWidth - iconWidth) / 2; // Align horizontally center. + drawIcon(canvas, icon, iconX, iconY, iconWidth, iconHeight); + } + + if (key.hasPopupHint() && key.getMoreKeys() != null) { + drawKeyPopupHint(key, canvas, paint, params); + } + } + + // Draw popup hint "..." at the bottom right corner of the key. + protected void drawKeyPopupHint(@Nonnull final Key key, @Nonnull final Canvas canvas, + @Nonnull final Paint paint, @Nonnull final KeyDrawParams params) { + if (TextUtils.isEmpty(mKeyPopupHintLetter)) { + return; + } + final int keyWidth = key.getDrawWidth(); + final int keyHeight = key.getHeight(); + + paint.setTypeface(params.mTypeface); + paint.setTextSize(params.mHintLetterSize); + paint.setColor(params.mHintLabelColor); + paint.setTextAlign(Align.CENTER); + final float hintX = keyWidth - mKeyHintLetterPadding + - TypefaceUtils.getReferenceCharWidth(paint) / 2.0f; + final float hintY = keyHeight - mKeyPopupHintLetterPadding; + canvas.drawText(mKeyPopupHintLetter, hintX, hintY, paint); + } + + protected static void drawIcon(@Nonnull final Canvas canvas,@Nonnull final Drawable icon, + final int x, final int y, final int width, final int height) { + canvas.translate(x, y); + icon.setBounds(0, 0, width, height); + icon.draw(canvas); + canvas.translate(-x, -y); + } + + public Paint newLabelPaint(@Nullable final Key key) { + final Paint paint = new Paint(); + paint.setAntiAlias(true); + if (key == null) { + paint.setTypeface(mKeyDrawParams.mTypeface); + paint.setTextSize(mKeyDrawParams.mLabelSize); + } else { + paint.setColor(key.selectTextColor(mKeyDrawParams)); + paint.setTypeface(key.selectTypeface(mKeyDrawParams)); + paint.setTextSize(key.selectTextSize(mKeyDrawParams)); + } + return paint; + } + + /** + * Requests a redraw of the entire keyboard. Calling {@link #invalidate} is not sufficient + * because the keyboard renders the keys to an off-screen buffer and an invalidate() only + * draws the cached buffer. + * @see #invalidateKey(Key) + */ + public void invalidateAllKeys() { + mInvalidatedKeys.clear(); + mInvalidateAllKeys = true; + invalidate(); + } + + /** + * Invalidates a key so that it will be redrawn on the next repaint. Use this method if only + * one key is changing it's content. Any changes that affect the position or size of the key + * may not be honored. + * @param key key in the attached {@link Keyboard}. + * @see #invalidateAllKeys + */ + public void invalidateKey(@Nullable final Key key) { + if (mInvalidateAllKeys || key == null) { + return; + } + mInvalidatedKeys.add(key); + final int x = key.getX() + getPaddingLeft(); + final int y = key.getY() + getPaddingTop(); + invalidate(x, y, x + key.getWidth(), y + key.getHeight()); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + freeOffscreenBuffer(); + } + + public void deallocateMemory() { + freeOffscreenBuffer(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/MainKeyboardView.java b/java/src/org/kelar/inputmethod/keyboard/MainKeyboardView.java new file mode 100644 index 000000000..48878e3ea --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/MainKeyboardView.java @@ -0,0 +1,893 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import android.animation.AnimatorInflater; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Typeface; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.accessibility.MainKeyboardAccessibilityDelegate; +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.keyboard.internal.DrawingPreviewPlacerView; +import org.kelar.inputmethod.keyboard.internal.DrawingProxy; +import org.kelar.inputmethod.keyboard.internal.GestureFloatingTextDrawingPreview; +import org.kelar.inputmethod.keyboard.internal.GestureTrailsDrawingPreview; +import org.kelar.inputmethod.keyboard.internal.KeyDrawParams; +import org.kelar.inputmethod.keyboard.internal.KeyPreviewChoreographer; +import org.kelar.inputmethod.keyboard.internal.KeyPreviewDrawParams; +import org.kelar.inputmethod.keyboard.internal.KeyPreviewView; +import org.kelar.inputmethod.keyboard.internal.MoreKeySpec; +import org.kelar.inputmethod.keyboard.internal.NonDistinctMultitouchHelper; +import org.kelar.inputmethod.keyboard.internal.SlidingKeyInputDrawingPreview; +import org.kelar.inputmethod.keyboard.internal.TimerHandler; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.settings.DebugSettings; +import org.kelar.inputmethod.latin.utils.LanguageOnSpacebarUtils; +import org.kelar.inputmethod.latin.utils.TypefaceUtils; + +import java.util.WeakHashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A view that is responsible for detecting key presses and touch movements. + * + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarTextRatio + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarTextColor + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarTextShadowRadius + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarTextShadowColor + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarFinalAlpha + * @attr ref android.R.styleable#MainKeyboardView_languageOnSpacebarFadeoutAnimator + * @attr ref android.R.styleable#MainKeyboardView_altCodeKeyWhileTypingFadeoutAnimator + * @attr ref android.R.styleable#MainKeyboardView_altCodeKeyWhileTypingFadeinAnimator + * @attr ref android.R.styleable#MainKeyboardView_keyHysteresisDistance + * @attr ref android.R.styleable#MainKeyboardView_touchNoiseThresholdTime + * @attr ref android.R.styleable#MainKeyboardView_touchNoiseThresholdDistance + * @attr ref android.R.styleable#MainKeyboardView_keySelectionByDraggingFinger + * @attr ref android.R.styleable#MainKeyboardView_keyRepeatStartTimeout + * @attr ref android.R.styleable#MainKeyboardView_keyRepeatInterval + * @attr ref android.R.styleable#MainKeyboardView_longPressKeyTimeout + * @attr ref android.R.styleable#MainKeyboardView_longPressShiftKeyTimeout + * @attr ref android.R.styleable#MainKeyboardView_ignoreAltCodeKeyTimeout + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewLayout + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewOffset + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewHeight + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewLingerTimeout + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewShowUpAnimator + * @attr ref android.R.styleable#MainKeyboardView_keyPreviewDismissAnimator + * @attr ref android.R.styleable#MainKeyboardView_moreKeysKeyboardLayout + * @attr ref android.R.styleable#MainKeyboardView_moreKeysKeyboardForActionLayout + * @attr ref android.R.styleable#MainKeyboardView_backgroundDimAlpha + * @attr ref android.R.styleable#MainKeyboardView_showMoreKeysKeyboardAtTouchPoint + * @attr ref android.R.styleable#MainKeyboardView_gestureFloatingPreviewTextLingerTimeout + * @attr ref android.R.styleable#MainKeyboardView_gestureStaticTimeThresholdAfterFastTyping + * @attr ref android.R.styleable#MainKeyboardView_gestureDetectFastMoveSpeedThreshold + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicThresholdDecayDuration + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicTimeThresholdFrom + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicTimeThresholdTo + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicDistanceThresholdFrom + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicDistanceThresholdTo + * @attr ref android.R.styleable#MainKeyboardView_gestureSamplingMinimumDistance + * @attr ref android.R.styleable#MainKeyboardView_gestureRecognitionMinimumTime + * @attr ref android.R.styleable#MainKeyboardView_gestureRecognitionSpeedThreshold + * @attr ref android.R.styleable#MainKeyboardView_suppressKeyPreviewAfterBatchInputDuration + */ +public final class MainKeyboardView extends KeyboardView implements DrawingProxy, + MoreKeysPanel.Controller { + private static final String TAG = MainKeyboardView.class.getSimpleName(); + + /** Listener for {@link KeyboardActionListener}. */ + private KeyboardActionListener mKeyboardActionListener; + + /* Space key and its icon and background. */ + private Key mSpaceKey; + // Stuff to draw language name on spacebar. + private final int mLanguageOnSpacebarFinalAlpha; + private ObjectAnimator mLanguageOnSpacebarFadeoutAnimator; + private int mLanguageOnSpacebarFormatType; + private boolean mHasMultipleEnabledIMEsOrSubtypes; + private int mLanguageOnSpacebarAnimAlpha = Constants.Color.ALPHA_OPAQUE; + private final float mLanguageOnSpacebarTextRatio; + private float mLanguageOnSpacebarTextSize; + private final int mLanguageOnSpacebarTextColor; + private final float mLanguageOnSpacebarTextShadowRadius; + private final int mLanguageOnSpacebarTextShadowColor; + private static final float LANGUAGE_ON_SPACEBAR_TEXT_SHADOW_RADIUS_DISABLED = -1.0f; + // The minimum x-scale to fit the language name on spacebar. + private static final float MINIMUM_XSCALE_OF_LANGUAGE_NAME = 0.8f; + + // Stuff to draw altCodeWhileTyping keys. + private final ObjectAnimator mAltCodeKeyWhileTypingFadeoutAnimator; + private final ObjectAnimator mAltCodeKeyWhileTypingFadeinAnimator; + private int mAltCodeKeyWhileTypingAnimAlpha = Constants.Color.ALPHA_OPAQUE; + + // Drawing preview placer view + private final DrawingPreviewPlacerView mDrawingPreviewPlacerView; + private final int[] mOriginCoords = CoordinateUtils.newInstance(); + private final GestureFloatingTextDrawingPreview mGestureFloatingTextDrawingPreview; + private final GestureTrailsDrawingPreview mGestureTrailsDrawingPreview; + private final SlidingKeyInputDrawingPreview mSlidingKeyInputDrawingPreview; + + // Key preview + private final KeyPreviewDrawParams mKeyPreviewDrawParams; + private final KeyPreviewChoreographer mKeyPreviewChoreographer; + + // More keys keyboard + private final Paint mBackgroundDimAlphaPaint = new Paint(); + private final View mMoreKeysKeyboardContainer; + private final View mMoreKeysKeyboardForActionContainer; + private final WeakHashMap<Key, Keyboard> mMoreKeysKeyboardCache = new WeakHashMap<>(); + private final boolean mConfigShowMoreKeysKeyboardAtTouchedPoint; + // More keys panel (used by both more keys keyboard and more suggestions view) + // TODO: Consider extending to support multiple more keys panels + private MoreKeysPanel mMoreKeysPanel; + + // Gesture floating preview text + // TODO: Make this parameter customizable by user via settings. + private int mGestureFloatingPreviewTextLingerTimeout; + + private final KeyDetector mKeyDetector; + private final NonDistinctMultitouchHelper mNonDistinctMultitouchHelper; + + private final TimerHandler mTimerHandler; + private final int mLanguageOnSpacebarHorizontalMargin; + + private MainKeyboardAccessibilityDelegate mAccessibilityDelegate; + + public MainKeyboardView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.mainKeyboardViewStyle); + } + + public MainKeyboardView(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + final DrawingPreviewPlacerView drawingPreviewPlacerView = + new DrawingPreviewPlacerView(context, attrs); + + final TypedArray mainKeyboardViewAttr = context.obtainStyledAttributes( + attrs, R.styleable.MainKeyboardView, defStyle, R.style.MainKeyboardView); + final int ignoreAltCodeKeyTimeout = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_ignoreAltCodeKeyTimeout, 0); + final int gestureRecognitionUpdateTime = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureRecognitionUpdateTime, 0); + mTimerHandler = new TimerHandler( + this, ignoreAltCodeKeyTimeout, gestureRecognitionUpdateTime); + + final float keyHysteresisDistance = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_keyHysteresisDistance, 0.0f); + final float keyHysteresisDistanceForSlidingModifier = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_keyHysteresisDistanceForSlidingModifier, 0.0f); + mKeyDetector = new KeyDetector( + keyHysteresisDistance, keyHysteresisDistanceForSlidingModifier); + + PointerTracker.init(mainKeyboardViewAttr, mTimerHandler, this /* DrawingProxy */); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final boolean forceNonDistinctMultitouch = prefs.getBoolean( + DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH, false); + final boolean hasDistinctMultitouch = context.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT) + && !forceNonDistinctMultitouch; + mNonDistinctMultitouchHelper = hasDistinctMultitouch ? null + : new NonDistinctMultitouchHelper(); + + final int backgroundDimAlpha = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_backgroundDimAlpha, 0); + mBackgroundDimAlphaPaint.setColor(Color.BLACK); + mBackgroundDimAlphaPaint.setAlpha(backgroundDimAlpha); + mLanguageOnSpacebarTextRatio = mainKeyboardViewAttr.getFraction( + R.styleable.MainKeyboardView_languageOnSpacebarTextRatio, 1, 1, 1.0f); + mLanguageOnSpacebarTextColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_languageOnSpacebarTextColor, 0); + mLanguageOnSpacebarTextShadowRadius = mainKeyboardViewAttr.getFloat( + R.styleable.MainKeyboardView_languageOnSpacebarTextShadowRadius, + LANGUAGE_ON_SPACEBAR_TEXT_SHADOW_RADIUS_DISABLED); + mLanguageOnSpacebarTextShadowColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_languageOnSpacebarTextShadowColor, 0); + mLanguageOnSpacebarFinalAlpha = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_languageOnSpacebarFinalAlpha, + Constants.Color.ALPHA_OPAQUE); + final int languageOnSpacebarFadeoutAnimatorResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_languageOnSpacebarFadeoutAnimator, 0); + final int altCodeKeyWhileTypingFadeoutAnimatorResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_altCodeKeyWhileTypingFadeoutAnimator, 0); + final int altCodeKeyWhileTypingFadeinAnimatorResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_altCodeKeyWhileTypingFadeinAnimator, 0); + + mKeyPreviewDrawParams = new KeyPreviewDrawParams(mainKeyboardViewAttr); + mKeyPreviewChoreographer = new KeyPreviewChoreographer(mKeyPreviewDrawParams); + + final int moreKeysKeyboardLayoutId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_moreKeysKeyboardLayout, 0); + final int moreKeysKeyboardForActionLayoutId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_moreKeysKeyboardForActionLayout, + moreKeysKeyboardLayoutId); + mConfigShowMoreKeysKeyboardAtTouchedPoint = mainKeyboardViewAttr.getBoolean( + R.styleable.MainKeyboardView_showMoreKeysKeyboardAtTouchedPoint, false); + + mGestureFloatingPreviewTextLingerTimeout = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureFloatingPreviewTextLingerTimeout, 0); + + mGestureFloatingTextDrawingPreview = new GestureFloatingTextDrawingPreview( + mainKeyboardViewAttr); + mGestureFloatingTextDrawingPreview.setDrawingView(drawingPreviewPlacerView); + + mGestureTrailsDrawingPreview = new GestureTrailsDrawingPreview(mainKeyboardViewAttr); + mGestureTrailsDrawingPreview.setDrawingView(drawingPreviewPlacerView); + + mSlidingKeyInputDrawingPreview = new SlidingKeyInputDrawingPreview(mainKeyboardViewAttr); + mSlidingKeyInputDrawingPreview.setDrawingView(drawingPreviewPlacerView); + mainKeyboardViewAttr.recycle(); + + mDrawingPreviewPlacerView = drawingPreviewPlacerView; + + final LayoutInflater inflater = LayoutInflater.from(getContext()); + mMoreKeysKeyboardContainer = inflater.inflate(moreKeysKeyboardLayoutId, null); + mMoreKeysKeyboardForActionContainer = inflater.inflate( + moreKeysKeyboardForActionLayoutId, null); + mLanguageOnSpacebarFadeoutAnimator = loadObjectAnimator( + languageOnSpacebarFadeoutAnimatorResId, this); + mAltCodeKeyWhileTypingFadeoutAnimator = loadObjectAnimator( + altCodeKeyWhileTypingFadeoutAnimatorResId, this); + mAltCodeKeyWhileTypingFadeinAnimator = loadObjectAnimator( + altCodeKeyWhileTypingFadeinAnimatorResId, this); + + mKeyboardActionListener = KeyboardActionListener.EMPTY_LISTENER; + + mLanguageOnSpacebarHorizontalMargin = (int)getResources().getDimension( + R.dimen.config_language_on_spacebar_horizontal_margin); + } + + @Override + public void setHardwareAcceleratedDrawingEnabled(final boolean enabled) { + super.setHardwareAcceleratedDrawingEnabled(enabled); + mDrawingPreviewPlacerView.setHardwareAcceleratedDrawingEnabled(enabled); + } + + private ObjectAnimator loadObjectAnimator(final int resId, final Object target) { + if (resId == 0) { + // TODO: Stop returning null. + return null; + } + final ObjectAnimator animator = (ObjectAnimator)AnimatorInflater.loadAnimator( + getContext(), resId); + if (animator != null) { + animator.setTarget(target); + } + return animator; + } + + private static void cancelAndStartAnimators(final ObjectAnimator animatorToCancel, + final ObjectAnimator animatorToStart) { + if (animatorToCancel == null || animatorToStart == null) { + // TODO: Stop using null as a no-operation animator. + return; + } + float startFraction = 0.0f; + if (animatorToCancel.isStarted()) { + animatorToCancel.cancel(); + startFraction = 1.0f - animatorToCancel.getAnimatedFraction(); + } + final long startTime = (long)(animatorToStart.getDuration() * startFraction); + animatorToStart.start(); + animatorToStart.setCurrentPlayTime(startTime); + } + + // Implements {@link DrawingProxy#startWhileTypingAnimation(int)}. + /** + * Called when a while-typing-animation should be started. + * @param fadeInOrOut {@link DrawingProxy#FADE_IN} starts while-typing-fade-in animation. + * {@link DrawingProxy#FADE_OUT} starts while-typing-fade-out animation. + */ + @Override + public void startWhileTypingAnimation(final int fadeInOrOut) { + switch (fadeInOrOut) { + case DrawingProxy.FADE_IN: + cancelAndStartAnimators( + mAltCodeKeyWhileTypingFadeoutAnimator, mAltCodeKeyWhileTypingFadeinAnimator); + break; + case DrawingProxy.FADE_OUT: + cancelAndStartAnimators( + mAltCodeKeyWhileTypingFadeinAnimator, mAltCodeKeyWhileTypingFadeoutAnimator); + break; + } + } + + @ExternallyReferenced + public int getLanguageOnSpacebarAnimAlpha() { + return mLanguageOnSpacebarAnimAlpha; + } + + @ExternallyReferenced + public void setLanguageOnSpacebarAnimAlpha(final int alpha) { + mLanguageOnSpacebarAnimAlpha = alpha; + invalidateKey(mSpaceKey); + } + + @ExternallyReferenced + public int getAltCodeKeyWhileTypingAnimAlpha() { + return mAltCodeKeyWhileTypingAnimAlpha; + } + + @ExternallyReferenced + public void setAltCodeKeyWhileTypingAnimAlpha(final int alpha) { + if (mAltCodeKeyWhileTypingAnimAlpha == alpha) { + return; + } + // Update the visual of alt-code-key-while-typing. + mAltCodeKeyWhileTypingAnimAlpha = alpha; + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return; + } + for (final Key key : keyboard.mAltCodeKeysWhileTyping) { + invalidateKey(key); + } + } + + public void setKeyboardActionListener(final KeyboardActionListener listener) { + mKeyboardActionListener = listener; + PointerTracker.setKeyboardActionListener(listener); + } + + // TODO: We should reconsider which coordinate system should be used to represent keyboard + // event. + public int getKeyX(final int x) { + return Constants.isValidCoordinate(x) ? mKeyDetector.getTouchX(x) : x; + } + + // TODO: We should reconsider which coordinate system should be used to represent keyboard + // event. + public int getKeyY(final int y) { + return Constants.isValidCoordinate(y) ? mKeyDetector.getTouchY(y) : y; + } + + /** + * Attaches a keyboard to this view. The keyboard can be switched at any time and the + * view will re-layout itself to accommodate the keyboard. + * @see Keyboard + * @see #getKeyboard() + * @param keyboard the keyboard to display in this view + */ + @Override + public void setKeyboard(final Keyboard keyboard) { + // Remove any pending messages, except dismissing preview and key repeat. + mTimerHandler.cancelLongPressTimers(); + super.setKeyboard(keyboard); + mKeyDetector.setKeyboard( + keyboard, -getPaddingLeft(), -getPaddingTop() + getVerticalCorrection()); + PointerTracker.setKeyDetector(mKeyDetector); + mMoreKeysKeyboardCache.clear(); + + mSpaceKey = keyboard.getKey(Constants.CODE_SPACE); + final int keyHeight = keyboard.mMostCommonKeyHeight - keyboard.mVerticalGap; + mLanguageOnSpacebarTextSize = keyHeight * mLanguageOnSpacebarTextRatio; + + if (AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + if (mAccessibilityDelegate == null) { + mAccessibilityDelegate = new MainKeyboardAccessibilityDelegate(this, mKeyDetector); + } + mAccessibilityDelegate.setKeyboard(keyboard); + } else { + mAccessibilityDelegate = null; + } + } + + /** + * Enables or disables the key preview popup. This is a popup that shows a magnified + * version of the depressed key. By default the preview is enabled. + * @param previewEnabled whether or not to enable the key feedback preview + * @param delay the delay after which the preview is dismissed + */ + public void setKeyPreviewPopupEnabled(final boolean previewEnabled, final int delay) { + mKeyPreviewDrawParams.setPopupEnabled(previewEnabled, delay); + } + + /** + * Enables or disables the key preview popup animations and set animations' parameters. + * + * @param hasCustomAnimationParams false to use the default key preview popup animations + * specified by keyPreviewShowUpAnimator and keyPreviewDismissAnimator attributes. + * true to override the default animations with the specified parameters. + * @param showUpStartXScale from this x-scale the show up animation will start. + * @param showUpStartYScale from this y-scale the show up animation will start. + * @param showUpDuration the duration of the show up animation in milliseconds. + * @param dismissEndXScale to this x-scale the dismiss animation will end. + * @param dismissEndYScale to this y-scale the dismiss animation will end. + * @param dismissDuration the duration of the dismiss animation in milliseconds. + */ + public void setKeyPreviewAnimationParams(final boolean hasCustomAnimationParams, + final float showUpStartXScale, final float showUpStartYScale, final int showUpDuration, + final float dismissEndXScale, final float dismissEndYScale, final int dismissDuration) { + mKeyPreviewDrawParams.setAnimationParams(hasCustomAnimationParams, + showUpStartXScale, showUpStartYScale, showUpDuration, + dismissEndXScale, dismissEndYScale, dismissDuration); + } + + private void locatePreviewPlacerView() { + getLocationInWindow(mOriginCoords); + mDrawingPreviewPlacerView.setKeyboardViewGeometry(mOriginCoords, getWidth(), getHeight()); + } + + private void installPreviewPlacerView() { + final View rootView = getRootView(); + if (rootView == null) { + Log.w(TAG, "Cannot find root view"); + return; + } + final ViewGroup windowContentView = (ViewGroup)rootView.findViewById(android.R.id.content); + // Note: It'd be very weird if we get null by android.R.id.content. + if (windowContentView == null) { + Log.w(TAG, "Cannot find android.R.id.content view to add DrawingPreviewPlacerView"); + return; + } + windowContentView.addView(mDrawingPreviewPlacerView); + } + + // Implements {@link DrawingProxy#onKeyPressed(Key,boolean)}. + @Override + public void onKeyPressed(@Nonnull final Key key, final boolean withPreview) { + key.onPressed(); + invalidateKey(key); + if (withPreview && !key.noKeyPreview()) { + showKeyPreview(key); + } + } + + private void showKeyPreview(@Nonnull final Key key) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return; + } + final KeyPreviewDrawParams previewParams = mKeyPreviewDrawParams; + if (!previewParams.isPopupEnabled()) { + previewParams.setVisibleOffset(-keyboard.mVerticalGap); + return; + } + + locatePreviewPlacerView(); + getLocationInWindow(mOriginCoords); + mKeyPreviewChoreographer.placeAndShowKeyPreview(key, keyboard.mIconsSet, getKeyDrawParams(), + getWidth(), mOriginCoords, mDrawingPreviewPlacerView, isHardwareAccelerated()); + } + + private void dismissKeyPreviewWithoutDelay(@Nonnull final Key key) { + mKeyPreviewChoreographer.dismissKeyPreview(key, false /* withAnimation */); + invalidateKey(key); + } + + // Implements {@link DrawingProxy#onKeyReleased(Key,boolean)}. + @Override + public void onKeyReleased(@Nonnull final Key key, final boolean withAnimation) { + key.onReleased(); + invalidateKey(key); + if (!key.noKeyPreview()) { + if (withAnimation) { + dismissKeyPreview(key); + } else { + dismissKeyPreviewWithoutDelay(key); + } + } + } + + private void dismissKeyPreview(@Nonnull final Key key) { + if (isHardwareAccelerated()) { + mKeyPreviewChoreographer.dismissKeyPreview(key, true /* withAnimation */); + return; + } + // TODO: Implement preference option to control key preview method and duration. + mTimerHandler.postDismissKeyPreview(key, mKeyPreviewDrawParams.getLingerTimeout()); + } + + public void setSlidingKeyInputPreviewEnabled(final boolean enabled) { + mSlidingKeyInputDrawingPreview.setPreviewEnabled(enabled); + } + + @Override + public void showSlidingKeyInputPreview(@Nullable final PointerTracker tracker) { + locatePreviewPlacerView(); + if (tracker != null) { + mSlidingKeyInputDrawingPreview.setPreviewPosition(tracker); + } else { + mSlidingKeyInputDrawingPreview.dismissSlidingKeyInputPreview(); + } + } + + private void setGesturePreviewMode(final boolean isGestureTrailEnabled, + final boolean isGestureFloatingPreviewTextEnabled) { + mGestureFloatingTextDrawingPreview.setPreviewEnabled(isGestureFloatingPreviewTextEnabled); + mGestureTrailsDrawingPreview.setPreviewEnabled(isGestureTrailEnabled); + } + + public void showGestureFloatingPreviewText(@Nonnull final SuggestedWords suggestedWords, + final boolean dismissDelayed) { + locatePreviewPlacerView(); + final GestureFloatingTextDrawingPreview gestureFloatingTextDrawingPreview = + mGestureFloatingTextDrawingPreview; + gestureFloatingTextDrawingPreview.setSuggetedWords(suggestedWords); + if (dismissDelayed) { + mTimerHandler.postDismissGestureFloatingPreviewText( + mGestureFloatingPreviewTextLingerTimeout); + } + } + + // Implements {@link DrawingProxy#dismissGestureFloatingPreviewTextWithoutDelay()}. + @Override + public void dismissGestureFloatingPreviewTextWithoutDelay() { + mGestureFloatingTextDrawingPreview.dismissGestureFloatingPreviewText(); + } + + @Override + public void showGestureTrail(@Nonnull final PointerTracker tracker, + final boolean showsFloatingPreviewText) { + locatePreviewPlacerView(); + if (showsFloatingPreviewText) { + mGestureFloatingTextDrawingPreview.setPreviewPosition(tracker); + } + mGestureTrailsDrawingPreview.setPreviewPosition(tracker); + } + + // Note that this method is called from a non-UI thread. + @SuppressWarnings("static-method") + public void setMainDictionaryAvailability(final boolean mainDictionaryAvailable) { + PointerTracker.setMainDictionaryAvailability(mainDictionaryAvailable); + } + + public void setGestureHandlingEnabledByUser(final boolean isGestureHandlingEnabledByUser, + final boolean isGestureTrailEnabled, + final boolean isGestureFloatingPreviewTextEnabled) { + PointerTracker.setGestureHandlingEnabledByUser(isGestureHandlingEnabledByUser); + setGesturePreviewMode(isGestureHandlingEnabledByUser && isGestureTrailEnabled, + isGestureHandlingEnabledByUser && isGestureFloatingPreviewTextEnabled); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + installPreviewPlacerView(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mDrawingPreviewPlacerView.removeAllViews(); + } + + // Implements {@link DrawingProxy@showMoreKeysKeyboard(Key,PointerTracker)}. + @Override + @Nullable + public MoreKeysPanel showMoreKeysKeyboard(@Nonnull final Key key, + @Nonnull final PointerTracker tracker) { + final MoreKeySpec[] moreKeys = key.getMoreKeys(); + if (moreKeys == null) { + return null; + } + Keyboard moreKeysKeyboard = mMoreKeysKeyboardCache.get(key); + if (moreKeysKeyboard == null) { + // {@link KeyPreviewDrawParams#mPreviewVisibleWidth} should have been set at + // {@link KeyPreviewChoreographer#placeKeyPreview(Key,TextView,KeyboardIconsSet,KeyDrawParams,int,int[]}, + // though there may be some chances that the value is zero. <code>width == 0</code> + // will cause zero-division error at + // {@link MoreKeysKeyboardParams#setParameters(int,int,int,int,int,int,boolean,int)}. + final boolean isSingleMoreKeyWithPreview = mKeyPreviewDrawParams.isPopupEnabled() + && !key.noKeyPreview() && moreKeys.length == 1 + && mKeyPreviewDrawParams.getVisibleWidth() > 0; + final MoreKeysKeyboard.Builder builder = new MoreKeysKeyboard.Builder( + getContext(), key, getKeyboard(), isSingleMoreKeyWithPreview, + mKeyPreviewDrawParams.getVisibleWidth(), + mKeyPreviewDrawParams.getVisibleHeight(), newLabelPaint(key)); + moreKeysKeyboard = builder.build(); + mMoreKeysKeyboardCache.put(key, moreKeysKeyboard); + } + + final View container = key.isActionKey() ? mMoreKeysKeyboardForActionContainer + : mMoreKeysKeyboardContainer; + final MoreKeysKeyboardView moreKeysKeyboardView = + (MoreKeysKeyboardView)container.findViewById(R.id.more_keys_keyboard_view); + moreKeysKeyboardView.setKeyboard(moreKeysKeyboard); + container.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + final int[] lastCoords = CoordinateUtils.newInstance(); + tracker.getLastCoordinates(lastCoords); + final boolean keyPreviewEnabled = mKeyPreviewDrawParams.isPopupEnabled() + && !key.noKeyPreview(); + // The more keys keyboard is usually horizontally aligned with the center of the parent key. + // If showMoreKeysKeyboardAtTouchedPoint is true and the key preview is disabled, the more + // keys keyboard is placed at the touch point of the parent key. + final int pointX = (mConfigShowMoreKeysKeyboardAtTouchedPoint && !keyPreviewEnabled) + ? CoordinateUtils.x(lastCoords) + : key.getX() + key.getWidth() / 2; + // The more keys keyboard is usually vertically aligned with the top edge of the parent key + // (plus vertical gap). If the key preview is enabled, the more keys keyboard is vertically + // aligned with the bottom edge of the visible part of the key preview. + // {@code mPreviewVisibleOffset} has been set appropriately in + // {@link KeyboardView#showKeyPreview(PointerTracker)}. + final int pointY = key.getY() + mKeyPreviewDrawParams.getVisibleOffset(); + moreKeysKeyboardView.showMoreKeysPanel(this, this, pointX, pointY, mKeyboardActionListener); + return moreKeysKeyboardView; + } + + public boolean isInDraggingFinger() { + if (isShowingMoreKeysPanel()) { + return true; + } + return PointerTracker.isAnyInDraggingFinger(); + } + + @Override + public void onShowMoreKeysPanel(final MoreKeysPanel panel) { + locatePreviewPlacerView(); + // Dismiss another {@link MoreKeysPanel} that may be being showed. + onDismissMoreKeysPanel(); + // Dismiss all key previews that may be being showed. + PointerTracker.setReleasedKeyGraphicsToAllKeys(); + // Dismiss sliding key input preview that may be being showed. + mSlidingKeyInputDrawingPreview.dismissSlidingKeyInputPreview(); + panel.showInParent(mDrawingPreviewPlacerView); + mMoreKeysPanel = panel; + } + + public boolean isShowingMoreKeysPanel() { + return mMoreKeysPanel != null && mMoreKeysPanel.isShowingInParent(); + } + + @Override + public void onCancelMoreKeysPanel() { + PointerTracker.dismissAllMoreKeysPanels(); + } + + @Override + public void onDismissMoreKeysPanel() { + if (isShowingMoreKeysPanel()) { + mMoreKeysPanel.removeFromParent(); + mMoreKeysPanel = null; + } + } + + public void startDoubleTapShiftKeyTimer() { + mTimerHandler.startDoubleTapShiftKeyTimer(); + } + + public void cancelDoubleTapShiftKeyTimer() { + mTimerHandler.cancelDoubleTapShiftKeyTimer(); + } + + public boolean isInDoubleTapShiftKeyTimeout() { + return mTimerHandler.isInDoubleTapShiftKeyTimeout(); + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (getKeyboard() == null) { + return false; + } + if (mNonDistinctMultitouchHelper != null) { + if (event.getPointerCount() > 1 && mTimerHandler.isInKeyRepeat()) { + // Key repeating timer will be canceled if 2 or more keys are in action. + mTimerHandler.cancelKeyRepeatTimers(); + } + // Non distinct multitouch screen support + mNonDistinctMultitouchHelper.processMotionEvent(event, mKeyDetector); + return true; + } + return processMotionEvent(event); + } + + public boolean processMotionEvent(final MotionEvent event) { + final int index = event.getActionIndex(); + final int id = event.getPointerId(index); + final PointerTracker tracker = PointerTracker.getPointerTracker(id); + // When a more keys panel is showing, we should ignore other fingers' single touch events + // other than the finger that is showing the more keys panel. + if (isShowingMoreKeysPanel() && !tracker.isShowingMoreKeysPanel() + && PointerTracker.getActivePointerTrackerCount() == 1) { + return true; + } + tracker.processMotionEvent(event, mKeyDetector); + return true; + } + + public void cancelAllOngoingEvents() { + mTimerHandler.cancelAllMessages(); + PointerTracker.setReleasedKeyGraphicsToAllKeys(); + mGestureFloatingTextDrawingPreview.dismissGestureFloatingPreviewText(); + mSlidingKeyInputDrawingPreview.dismissSlidingKeyInputPreview(); + PointerTracker.dismissAllMoreKeysPanels(); + PointerTracker.cancelAllPointerTrackers(); + } + + public void closing() { + cancelAllOngoingEvents(); + mMoreKeysKeyboardCache.clear(); + } + + public void onHideWindow() { + onDismissMoreKeysPanel(); + final MainKeyboardAccessibilityDelegate accessibilityDelegate = mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + accessibilityDelegate.onHideWindow(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean onHoverEvent(final MotionEvent event) { + final MainKeyboardAccessibilityDelegate accessibilityDelegate = mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isTouchExplorationEnabled()) { + return accessibilityDelegate.onHoverEvent(event); + } + return super.onHoverEvent(event); + } + + public void updateShortcutKey(final boolean available) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return; + } + final Key shortcutKey = keyboard.getKey(Constants.CODE_SHORTCUT); + if (shortcutKey == null) { + return; + } + shortcutKey.setEnabled(available); + invalidateKey(shortcutKey); + } + + public void startDisplayLanguageOnSpacebar(final boolean subtypeChanged, + final int languageOnSpacebarFormatType, + final boolean hasMultipleEnabledIMEsOrSubtypes) { + if (subtypeChanged) { + KeyPreviewView.clearTextCache(); + } + mLanguageOnSpacebarFormatType = languageOnSpacebarFormatType; + mHasMultipleEnabledIMEsOrSubtypes = hasMultipleEnabledIMEsOrSubtypes; + final ObjectAnimator animator = mLanguageOnSpacebarFadeoutAnimator; + if (animator == null) { + mLanguageOnSpacebarFormatType = LanguageOnSpacebarUtils.FORMAT_TYPE_NONE; + } else { + if (subtypeChanged + && languageOnSpacebarFormatType != LanguageOnSpacebarUtils.FORMAT_TYPE_NONE) { + setLanguageOnSpacebarAnimAlpha(Constants.Color.ALPHA_OPAQUE); + if (animator.isStarted()) { + animator.cancel(); + } + animator.start(); + } else { + if (!animator.isStarted()) { + mLanguageOnSpacebarAnimAlpha = mLanguageOnSpacebarFinalAlpha; + } + } + } + invalidateKey(mSpaceKey); + } + + @Override + protected void onDrawKeyTopVisuals(final Key key, final Canvas canvas, final Paint paint, + final KeyDrawParams params) { + if (key.altCodeWhileTyping() && key.isEnabled()) { + params.mAnimAlpha = mAltCodeKeyWhileTypingAnimAlpha; + } + super.onDrawKeyTopVisuals(key, canvas, paint, params); + final int code = key.getCode(); + if (code == Constants.CODE_SPACE) { + // If input language are explicitly selected. + if (mLanguageOnSpacebarFormatType != LanguageOnSpacebarUtils.FORMAT_TYPE_NONE) { + drawLanguageOnSpacebar(key, canvas, paint); + } + // Whether space key needs to show the "..." popup hint for special purposes + if (key.isLongPressEnabled() && mHasMultipleEnabledIMEsOrSubtypes) { + drawKeyPopupHint(key, canvas, paint, params); + } + } else if (code == Constants.CODE_LANGUAGE_SWITCH) { + drawKeyPopupHint(key, canvas, paint, params); + } + } + + private boolean fitsTextIntoWidth(final int width, final String text, final Paint paint) { + final int maxTextWidth = width - mLanguageOnSpacebarHorizontalMargin * 2; + paint.setTextScaleX(1.0f); + final float textWidth = TypefaceUtils.getStringWidth(text, paint); + if (textWidth < width) { + return true; + } + + final float scaleX = maxTextWidth / textWidth; + if (scaleX < MINIMUM_XSCALE_OF_LANGUAGE_NAME) { + return false; + } + + paint.setTextScaleX(scaleX); + return TypefaceUtils.getStringWidth(text, paint) < maxTextWidth; + } + + // Layout language name on spacebar. + private String layoutLanguageOnSpacebar(final Paint paint, + final RichInputMethodSubtype subtype, final int width) { + // Choose appropriate language name to fit into the width. + if (mLanguageOnSpacebarFormatType == LanguageOnSpacebarUtils.FORMAT_TYPE_FULL_LOCALE) { + final String fullText = subtype.getFullDisplayName(); + if (fitsTextIntoWidth(width, fullText, paint)) { + return fullText; + } + } + + final String middleText = subtype.getMiddleDisplayName(); + if (fitsTextIntoWidth(width, middleText, paint)) { + return middleText; + } + + return ""; + } + + private void drawLanguageOnSpacebar(final Key key, final Canvas canvas, final Paint paint) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return; + } + final int width = key.getWidth(); + final int height = key.getHeight(); + paint.setTextAlign(Align.CENTER); + paint.setTypeface(Typeface.DEFAULT); + paint.setTextSize(mLanguageOnSpacebarTextSize); + final String language = layoutLanguageOnSpacebar(paint, keyboard.mId.mSubtype, width); + // Draw language text with shadow + final float descent = paint.descent(); + final float textHeight = -paint.ascent() + descent; + final float baseline = height / 2 + textHeight / 2; + if (mLanguageOnSpacebarTextShadowRadius > 0.0f) { + paint.setShadowLayer(mLanguageOnSpacebarTextShadowRadius, 0, 0, + mLanguageOnSpacebarTextShadowColor); + } else { + paint.clearShadowLayer(); + } + paint.setColor(mLanguageOnSpacebarTextColor); + paint.setAlpha(mLanguageOnSpacebarAnimAlpha); + canvas.drawText(language, width / 2, baseline - descent, paint); + paint.clearShadowLayer(); + paint.setTextScaleX(1.0f); + } + + @Override + public void deallocateMemory() { + super.deallocateMemory(); + mDrawingPreviewPlacerView.deallocateMemory(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/MoreKeysDetector.java b/java/src/org/kelar/inputmethod/keyboard/MoreKeysDetector.java new file mode 100644 index 000000000..d07314c25 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/MoreKeysDetector.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +public final class MoreKeysDetector extends KeyDetector { + private final int mSlideAllowanceSquare; + private final int mSlideAllowanceSquareTop; + + public MoreKeysDetector(float slideAllowance) { + super(); + mSlideAllowanceSquare = (int)(slideAllowance * slideAllowance); + // Top slide allowance is slightly longer (sqrt(2) times) than other edges. + mSlideAllowanceSquareTop = mSlideAllowanceSquare * 2; + } + + @Override + public boolean alwaysAllowsKeySelectionByDraggingFinger() { + return true; + } + + @Override + public Key detectHitKey(final int x, final int y) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return null; + } + final int touchX = getTouchX(x); + final int touchY = getTouchY(y); + + Key nearestKey = null; + int nearestDist = (y < 0) ? mSlideAllowanceSquareTop : mSlideAllowanceSquare; + for (final Key key : keyboard.getSortedKeys()) { + final int dist = key.squaredDistanceToEdge(touchX, touchY); + if (dist < nearestDist) { + nearestKey = key; + nearestDist = dist; + } + } + return nearestKey; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboard.java b/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboard.java new file mode 100644 index 000000000..d24b9f87d --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboard.java @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import android.content.Context; +import android.graphics.Paint; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.internal.KeyboardBuilder; +import org.kelar.inputmethod.keyboard.internal.KeyboardParams; +import org.kelar.inputmethod.keyboard.internal.MoreKeySpec; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.utils.TypefaceUtils; + +import javax.annotation.Nonnull; + +public final class MoreKeysKeyboard extends Keyboard { + private final int mDefaultKeyCoordX; + + MoreKeysKeyboard(final MoreKeysKeyboardParams params) { + super(params); + mDefaultKeyCoordX = params.getDefaultKeyCoordX() + params.mDefaultKeyWidth / 2; + } + + public int getDefaultCoordX() { + return mDefaultKeyCoordX; + } + + @UsedForTesting + static class MoreKeysKeyboardParams extends KeyboardParams { + public boolean mIsMoreKeysFixedOrder; + /* package */int mTopRowAdjustment; + public int mNumRows; + public int mNumColumns; + public int mTopKeys; + public int mLeftKeys; + public int mRightKeys; // includes default key. + public int mDividerWidth; + public int mColumnWidth; + + public MoreKeysKeyboardParams() { + super(); + } + + /** + * Set keyboard parameters of more keys keyboard. + * + * @param numKeys number of keys in this more keys keyboard. + * @param numColumn number of columns of this more keys keyboard. + * @param keyWidth more keys keyboard key width in pixel, including horizontal gap. + * @param rowHeight more keys keyboard row height in pixel, including vertical gap. + * @param coordXInParent coordinate x of the key preview in parent keyboard. + * @param parentKeyboardWidth parent keyboard width in pixel. + * @param isMoreKeysFixedColumn true if more keys keyboard should have + * <code>numColumn</code> columns. Otherwise more keys keyboard should have + * <code>numColumn</code> columns at most. + * @param isMoreKeysFixedOrder true if the order of more keys is determined by the order in + * the more keys' specification. Otherwise the order of more keys is automatically + * determined. + * @param dividerWidth width of divider, zero for no dividers. + */ + public void setParameters(final int numKeys, final int numColumn, final int keyWidth, + final int rowHeight, final int coordXInParent, final int parentKeyboardWidth, + final boolean isMoreKeysFixedColumn, final boolean isMoreKeysFixedOrder, + final int dividerWidth) { + mIsMoreKeysFixedOrder = isMoreKeysFixedOrder; + if (parentKeyboardWidth / keyWidth < Math.min(numKeys, numColumn)) { + throw new IllegalArgumentException("Keyboard is too small to hold more keys: " + + parentKeyboardWidth + " " + keyWidth + " " + numKeys + " " + numColumn); + } + mDefaultKeyWidth = keyWidth; + mDefaultRowHeight = rowHeight; + + final int numRows = (numKeys + numColumn - 1) / numColumn; + mNumRows = numRows; + final int numColumns = isMoreKeysFixedColumn ? Math.min(numKeys, numColumn) + : getOptimizedColumns(numKeys, numColumn); + mNumColumns = numColumns; + final int topKeys = numKeys % numColumns; + mTopKeys = topKeys == 0 ? numColumns : topKeys; + + final int numLeftKeys = (numColumns - 1) / 2; + final int numRightKeys = numColumns - numLeftKeys; // including default key. + // Maximum number of keys we can layout both side of the parent key + final int maxLeftKeys = coordXInParent / keyWidth; + final int maxRightKeys = (parentKeyboardWidth - coordXInParent) / keyWidth; + int leftKeys, rightKeys; + if (numLeftKeys > maxLeftKeys) { + leftKeys = maxLeftKeys; + rightKeys = numColumns - leftKeys; + } else if (numRightKeys > maxRightKeys + 1) { + rightKeys = maxRightKeys + 1; // include default key + leftKeys = numColumns - rightKeys; + } else { + leftKeys = numLeftKeys; + rightKeys = numRightKeys; + } + // If the left keys fill the left side of the parent key, entire more keys keyboard + // should be shifted to the right unless the parent key is on the left edge. + if (maxLeftKeys == leftKeys && leftKeys > 0) { + leftKeys--; + rightKeys++; + } + // If the right keys fill the right side of the parent key, entire more keys + // should be shifted to the left unless the parent key is on the right edge. + if (maxRightKeys == rightKeys - 1 && rightKeys > 1) { + leftKeys++; + rightKeys--; + } + mLeftKeys = leftKeys; + mRightKeys = rightKeys; + + // Adjustment of the top row. + mTopRowAdjustment = isMoreKeysFixedOrder ? getFixedOrderTopRowAdjustment() + : getAutoOrderTopRowAdjustment(); + mDividerWidth = dividerWidth; + mColumnWidth = mDefaultKeyWidth + mDividerWidth; + mBaseWidth = mOccupiedWidth = mNumColumns * mColumnWidth - mDividerWidth; + // Need to subtract the bottom row's gutter only. + mBaseHeight = mOccupiedHeight = mNumRows * mDefaultRowHeight - mVerticalGap + + mTopPadding + mBottomPadding; + } + + private int getFixedOrderTopRowAdjustment() { + if (mNumRows == 1 || mTopKeys % 2 == 1 || mTopKeys == mNumColumns + || mLeftKeys == 0 || mRightKeys == 1) { + return 0; + } + return -1; + } + + private int getAutoOrderTopRowAdjustment() { + if (mNumRows == 1 || mTopKeys == 1 || mNumColumns % 2 == mTopKeys % 2 + || mLeftKeys == 0 || mRightKeys == 1) { + return 0; + } + return -1; + } + + // Return key position according to column count (0 is default). + /* package */int getColumnPos(final int n) { + return mIsMoreKeysFixedOrder ? getFixedOrderColumnPos(n) : getAutomaticColumnPos(n); + } + + private int getFixedOrderColumnPos(final int n) { + final int col = n % mNumColumns; + final int row = n / mNumColumns; + if (!isTopRow(row)) { + return col - mLeftKeys; + } + final int rightSideKeys = mTopKeys / 2; + final int leftSideKeys = mTopKeys - (rightSideKeys + 1); + final int pos = col - leftSideKeys; + final int numLeftKeys = mLeftKeys + mTopRowAdjustment; + final int numRightKeys = mRightKeys - 1; + if (numRightKeys >= rightSideKeys && numLeftKeys >= leftSideKeys) { + return pos; + } else if (numRightKeys < rightSideKeys) { + return pos - (rightSideKeys - numRightKeys); + } else { // numLeftKeys < leftSideKeys + return pos + (leftSideKeys - numLeftKeys); + } + } + + private int getAutomaticColumnPos(final int n) { + final int col = n % mNumColumns; + final int row = n / mNumColumns; + int leftKeys = mLeftKeys; + if (isTopRow(row)) { + leftKeys += mTopRowAdjustment; + } + if (col == 0) { + // default position. + return 0; + } + + int pos = 0; + int right = 1; // include default position key. + int left = 0; + int i = 0; + while (true) { + // Assign right key if available. + if (right < mRightKeys) { + pos = right; + right++; + i++; + } + if (i >= col) + break; + // Assign left key if available. + if (left < leftKeys) { + left++; + pos = -left; + i++; + } + if (i >= col) + break; + } + return pos; + } + + private static int getTopRowEmptySlots(final int numKeys, final int numColumns) { + final int remainings = numKeys % numColumns; + return remainings == 0 ? 0 : numColumns - remainings; + } + + private int getOptimizedColumns(final int numKeys, final int maxColumns) { + int numColumns = Math.min(numKeys, maxColumns); + while (getTopRowEmptySlots(numKeys, numColumns) >= mNumRows) { + numColumns--; + } + return numColumns; + } + + public int getDefaultKeyCoordX() { + return mLeftKeys * mColumnWidth + mLeftPadding; + } + + public int getX(final int n, final int row) { + final int x = getColumnPos(n) * mColumnWidth + getDefaultKeyCoordX(); + if (isTopRow(row)) { + return x + mTopRowAdjustment * (mColumnWidth / 2); + } + return x; + } + + public int getY(final int row) { + return (mNumRows - 1 - row) * mDefaultRowHeight + mTopPadding; + } + + public void markAsEdgeKey(final Key key, final int row) { + if (row == 0) + key.markAsTopEdge(this); + if (isTopRow(row)) + key.markAsBottomEdge(this); + } + + private boolean isTopRow(final int rowCount) { + return mNumRows > 1 && rowCount == mNumRows - 1; + } + } + + public static class Builder extends KeyboardBuilder<MoreKeysKeyboardParams> { + private final Key mParentKey; + + private static final float LABEL_PADDING_RATIO = 0.2f; + private static final float DIVIDER_RATIO = 0.2f; + + /** + * The builder of MoreKeysKeyboard. + * @param context the context of {@link MoreKeysKeyboardView}. + * @param key the {@link Key} that invokes more keys keyboard. + * @param keyboard the {@link Keyboard} that contains the parentKey. + * @param isSingleMoreKeyWithPreview true if the <code>key</code> has just a single + * "more key" and its key popup preview is enabled. + * @param keyPreviewVisibleWidth the width of visible part of key popup preview. + * @param keyPreviewVisibleHeight the height of visible part of key popup preview + * @param paintToMeasure the {@link Paint} object to measure a "more key" width + */ + public Builder(final Context context, final Key key, final Keyboard keyboard, + final boolean isSingleMoreKeyWithPreview, final int keyPreviewVisibleWidth, + final int keyPreviewVisibleHeight, final Paint paintToMeasure) { + super(context, new MoreKeysKeyboardParams()); + load(keyboard.mMoreKeysTemplate, keyboard.mId); + + // TODO: More keys keyboard's vertical gap is currently calculated heuristically. + // Should revise the algorithm. + mParams.mVerticalGap = keyboard.mVerticalGap / 2; + // This {@link MoreKeysKeyboard} is invoked from the <code>key</code>. + mParentKey = key; + + final int keyWidth, rowHeight; + if (isSingleMoreKeyWithPreview) { + // Use pre-computed width and height if this more keys keyboard has only one key to + // mitigate visual flicker between key preview and more keys keyboard. + // Caveats for the visual assets: To achieve this effect, both the key preview + // backgrounds and the more keys keyboard panel background have the exact same + // left/right/top paddings. The bottom paddings of both backgrounds don't need to + // be considered because the vertical positions of both backgrounds were already + // adjusted with their bottom paddings deducted. + keyWidth = keyPreviewVisibleWidth; + rowHeight = keyPreviewVisibleHeight + mParams.mVerticalGap; + } else { + final float padding = context.getResources().getDimension( + R.dimen.config_more_keys_keyboard_key_horizontal_padding) + + (key.hasLabelsInMoreKeys() + ? mParams.mDefaultKeyWidth * LABEL_PADDING_RATIO : 0.0f); + keyWidth = getMaxKeyWidth(key, mParams.mDefaultKeyWidth, padding, paintToMeasure); + rowHeight = keyboard.mMostCommonKeyHeight; + } + final int dividerWidth; + if (key.needsDividersInMoreKeys()) { + dividerWidth = (int)(keyWidth * DIVIDER_RATIO); + } else { + dividerWidth = 0; + } + final MoreKeySpec[] moreKeys = key.getMoreKeys(); + mParams.setParameters(moreKeys.length, key.getMoreKeysColumnNumber(), keyWidth, + rowHeight, key.getX() + key.getWidth() / 2, keyboard.mId.mWidth, + key.isMoreKeysFixedColumn(), key.isMoreKeysFixedOrder(), dividerWidth); + } + + private static int getMaxKeyWidth(final Key parentKey, final int minKeyWidth, + final float padding, final Paint paint) { + int maxWidth = minKeyWidth; + for (final MoreKeySpec spec : parentKey.getMoreKeys()) { + final String label = spec.mLabel; + // If the label is single letter, minKeyWidth is enough to hold the label. + if (label != null && StringUtils.codePointCount(label) > 1) { + maxWidth = Math.max(maxWidth, + (int)(TypefaceUtils.getStringWidth(label, paint) + padding)); + } + } + return maxWidth; + } + + @Override + @Nonnull + public MoreKeysKeyboard build() { + final MoreKeysKeyboardParams params = mParams; + final int moreKeyFlags = mParentKey.getMoreKeyLabelFlags(); + final MoreKeySpec[] moreKeys = mParentKey.getMoreKeys(); + for (int n = 0; n < moreKeys.length; n++) { + final MoreKeySpec moreKeySpec = moreKeys[n]; + final int row = n / params.mNumColumns; + final int x = params.getX(n, row); + final int y = params.getY(row); + final Key key = moreKeySpec.buildKey(x, y, moreKeyFlags, params); + params.markAsEdgeKey(key, row); + params.onAddKey(key); + + final int pos = params.getColumnPos(n); + // The "pos" value represents the offset from the default position. Negative means + // left of the default position. + if (params.mDividerWidth > 0 && pos != 0) { + final int dividerX = (pos > 0) ? x - params.mDividerWidth + : x + params.mDefaultKeyWidth; + final Key divider = new MoreKeyDivider( + params, dividerX, y, params.mDividerWidth, params.mDefaultRowHeight); + params.onAddKey(divider); + } + } + return new MoreKeysKeyboard(params); + } + } + + // Used as a divider maker. A divider is drawn by {@link MoreKeysKeyboardView}. + public static class MoreKeyDivider extends Key.Spacer { + public MoreKeyDivider(final KeyboardParams params, final int x, final int y, + final int width, final int height) { + super(params, x, y, width, height); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboardView.java b/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboardView.java new file mode 100644 index 000000000..ee66b8618 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboardView.java @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.accessibility.MoreKeysKeyboardAccessibilityDelegate; +import org.kelar.inputmethod.keyboard.internal.KeyDrawParams; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +/** + * A view that renders a virtual {@link MoreKeysKeyboard}. It handles rendering of keys and + * detecting key presses and touch movements. + */ +public class MoreKeysKeyboardView extends KeyboardView implements MoreKeysPanel { + private final int[] mCoordinates = CoordinateUtils.newInstance(); + + private final Drawable mDivider; + protected final KeyDetector mKeyDetector; + private Controller mController = EMPTY_CONTROLLER; + protected KeyboardActionListener mListener; + private int mOriginX; + private int mOriginY; + private Key mCurrentKey; + + private int mActivePointerId; + + protected MoreKeysKeyboardAccessibilityDelegate mAccessibilityDelegate; + + public MoreKeysKeyboardView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.moreKeysKeyboardViewStyle); + } + + public MoreKeysKeyboardView(final Context context, final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + final TypedArray moreKeysKeyboardViewAttr = context.obtainStyledAttributes(attrs, + R.styleable.MoreKeysKeyboardView, defStyle, R.style.MoreKeysKeyboardView); + mDivider = moreKeysKeyboardViewAttr.getDrawable(R.styleable.MoreKeysKeyboardView_divider); + if (mDivider != null) { + // TODO: Drawable itself should have an alpha value. + mDivider.setAlpha(128); + } + moreKeysKeyboardViewAttr.recycle(); + mKeyDetector = new MoreKeysDetector(getResources().getDimension( + R.dimen.config_more_keys_keyboard_slide_allowance)); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final Keyboard keyboard = getKeyboard(); + if (keyboard != null) { + final int width = keyboard.mOccupiedWidth + getPaddingLeft() + getPaddingRight(); + final int height = keyboard.mOccupiedHeight + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension(width, height); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + @Override + protected void onDrawKeyTopVisuals(final Key key, final Canvas canvas, final Paint paint, + final KeyDrawParams params) { + if (!key.isSpacer() || !(key instanceof MoreKeysKeyboard.MoreKeyDivider) + || mDivider == null) { + super.onDrawKeyTopVisuals(key, canvas, paint, params); + return; + } + final int keyWidth = key.getDrawWidth(); + final int keyHeight = key.getHeight(); + final int iconWidth = Math.min(mDivider.getIntrinsicWidth(), keyWidth); + final int iconHeight = mDivider.getIntrinsicHeight(); + final int iconX = (keyWidth - iconWidth) / 2; // Align horizontally center + final int iconY = (keyHeight - iconHeight) / 2; // Align vertically center + drawIcon(canvas, mDivider, iconX, iconY, iconWidth, iconHeight); + } + + @Override + public void setKeyboard(final Keyboard keyboard) { + super.setKeyboard(keyboard); + mKeyDetector.setKeyboard( + keyboard, -getPaddingLeft(), -getPaddingTop() + getVerticalCorrection()); + if (AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + if (mAccessibilityDelegate == null) { + mAccessibilityDelegate = new MoreKeysKeyboardAccessibilityDelegate( + this, mKeyDetector); + mAccessibilityDelegate.setOpenAnnounce(R.string.spoken_open_more_keys_keyboard); + mAccessibilityDelegate.setCloseAnnounce(R.string.spoken_close_more_keys_keyboard); + } + mAccessibilityDelegate.setKeyboard(keyboard); + } else { + mAccessibilityDelegate = null; + } + } + + @Override + public void showMoreKeysPanel(final View parentView, final Controller controller, + final int pointX, final int pointY, final KeyboardActionListener listener) { + mController = controller; + mListener = listener; + final View container = getContainerView(); + // The coordinates of panel's left-top corner in parentView's coordinate system. + // We need to consider background drawable paddings. + final int x = pointX - getDefaultCoordX() - container.getPaddingLeft() - getPaddingLeft(); + final int y = pointY - container.getMeasuredHeight() + container.getPaddingBottom() + + getPaddingBottom(); + + parentView.getLocationInWindow(mCoordinates); + // Ensure the horizontal position of the panel does not extend past the parentView edges. + final int maxX = parentView.getMeasuredWidth() - container.getMeasuredWidth(); + final int panelX = Math.max(0, Math.min(maxX, x)) + CoordinateUtils.x(mCoordinates); + final int panelY = y + CoordinateUtils.y(mCoordinates); + container.setX(panelX); + container.setY(panelY); + + mOriginX = x + container.getPaddingLeft(); + mOriginY = y + container.getPaddingTop(); + controller.onShowMoreKeysPanel(this); + final MoreKeysKeyboardAccessibilityDelegate accessibilityDelegate = mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + accessibilityDelegate.onShowMoreKeysKeyboard(); + } + } + + /** + * Returns the default x coordinate for showing this panel. + */ + protected int getDefaultCoordX() { + return ((MoreKeysKeyboard)getKeyboard()).getDefaultCoordX(); + } + + @Override + public void onDownEvent(final int x, final int y, final int pointerId, final long eventTime) { + mActivePointerId = pointerId; + mCurrentKey = detectKey(x, y); + } + + @Override + public void onMoveEvent(final int x, final int y, final int pointerId, final long eventTime) { + if (mActivePointerId != pointerId) { + return; + } + final boolean hasOldKey = (mCurrentKey != null); + mCurrentKey = detectKey(x, y); + if (hasOldKey && mCurrentKey == null) { + // A more keys keyboard is canceled when detecting no key. + mController.onCancelMoreKeysPanel(); + } + } + + @Override + public void onUpEvent(final int x, final int y, final int pointerId, final long eventTime) { + if (mActivePointerId != pointerId) { + return; + } + // Calling {@link #detectKey(int,int,int)} here is harmless because the last move event and + // the following up event share the same coordinates. + mCurrentKey = detectKey(x, y); + if (mCurrentKey != null) { + updateReleaseKeyGraphics(mCurrentKey); + onKeyInput(mCurrentKey, x, y); + mCurrentKey = null; + } + } + + /** + * Performs the specific action for this panel when the user presses a key on the panel. + */ + protected void onKeyInput(final Key key, final int x, final int y) { + final int code = key.getCode(); + if (code == Constants.CODE_OUTPUT_TEXT) { + mListener.onTextInput(mCurrentKey.getOutputText()); + } else if (code != Constants.CODE_UNSPECIFIED) { + if (getKeyboard().hasProximityCharsCorrection(code)) { + mListener.onCodeInput(code, x, y, false /* isKeyRepeat */); + } else { + mListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, + false /* isKeyRepeat */); + } + } + } + + private Key detectKey(int x, int y) { + final Key oldKey = mCurrentKey; + final Key newKey = mKeyDetector.detectHitKey(x, y); + if (newKey == oldKey) { + return newKey; + } + // A new key is detected. + if (oldKey != null) { + updateReleaseKeyGraphics(oldKey); + invalidateKey(oldKey); + } + if (newKey != null) { + updatePressKeyGraphics(newKey); + invalidateKey(newKey); + } + return newKey; + } + + private void updateReleaseKeyGraphics(final Key key) { + key.onReleased(); + invalidateKey(key); + } + + private void updatePressKeyGraphics(final Key key) { + key.onPressed(); + invalidateKey(key); + } + + @Override + public void dismissMoreKeysPanel() { + if (!isShowingInParent()) { + return; + } + final MoreKeysKeyboardAccessibilityDelegate accessibilityDelegate = mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + accessibilityDelegate.onDismissMoreKeysKeyboard(); + } + mController.onDismissMoreKeysPanel(); + } + + @Override + public int translateX(final int x) { + return x - mOriginX; + } + + @Override + public int translateY(final int y) { + return y - mOriginY; + } + + @Override + public boolean onTouchEvent(final MotionEvent me) { + final int action = me.getActionMasked(); + final long eventTime = me.getEventTime(); + final int index = me.getActionIndex(); + final int x = (int)me.getX(index); + final int y = (int)me.getY(index); + final int pointerId = me.getPointerId(index); + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + onDownEvent(x, y, pointerId, eventTime); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + onUpEvent(x, y, pointerId, eventTime); + break; + case MotionEvent.ACTION_MOVE: + onMoveEvent(x, y, pointerId, eventTime); + break; + } + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean onHoverEvent(final MotionEvent event) { + final MoreKeysKeyboardAccessibilityDelegate accessibilityDelegate = mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isTouchExplorationEnabled()) { + return accessibilityDelegate.onHoverEvent(event); + } + return super.onHoverEvent(event); + } + + private View getContainerView() { + return (View)getParent(); + } + + @Override + public void showInParent(final ViewGroup parentView) { + removeFromParent(); + parentView.addView(getContainerView()); + } + + @Override + public void removeFromParent() { + final View containerView = getContainerView(); + final ViewGroup currentParent = (ViewGroup)containerView.getParent(); + if (currentParent != null) { + currentParent.removeView(containerView); + } + } + + @Override + public boolean isShowingInParent() { + return (getContainerView().getParent() != null); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/MoreKeysPanel.java b/java/src/org/kelar/inputmethod/keyboard/MoreKeysPanel.java new file mode 100644 index 000000000..d58d542db --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/MoreKeysPanel.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import android.view.View; +import android.view.ViewGroup; + +public interface MoreKeysPanel { + public interface Controller { + /** + * Add the {@link MoreKeysPanel} to the target view. + * @param panel the panel to be shown. + */ + public void onShowMoreKeysPanel(final MoreKeysPanel panel); + + /** + * Remove the current {@link MoreKeysPanel} from the target view. + */ + public void onDismissMoreKeysPanel(); + + /** + * Instructs the parent to cancel the panel (e.g., when entering a different input mode). + */ + public void onCancelMoreKeysPanel(); + } + + public static final Controller EMPTY_CONTROLLER = new Controller() { + @Override + public void onShowMoreKeysPanel(final MoreKeysPanel panel) {} + @Override + public void onDismissMoreKeysPanel() {} + @Override + public void onCancelMoreKeysPanel() {} + }; + + /** + * Initializes the layout and event handling of this {@link MoreKeysPanel} and calls the + * controller's onShowMoreKeysPanel to add the panel's container view. + * + * @param parentView the parent view of this {@link MoreKeysPanel} + * @param controller the controller that can dismiss this {@link MoreKeysPanel} + * @param pointX x coordinate of this {@link MoreKeysPanel} + * @param pointY y coordinate of this {@link MoreKeysPanel} + * @param listener the listener that will receive keyboard action from this + * {@link MoreKeysPanel}. + */ + // TODO: Currently the MoreKeysPanel is inside a container view that is added to the parent. + // Consider the simpler approach of placing the MoreKeysPanel itself into the parent view. + public void showMoreKeysPanel(View parentView, Controller controller, int pointX, + int pointY, KeyboardActionListener listener); + + /** + * Dismisses the more keys panel and calls the controller's onDismissMoreKeysPanel to remove + * the panel's container view. + */ + public void dismissMoreKeysPanel(); + + /** + * Process a move event on the more keys panel. + * + * @param x translated x coordinate of the touch point + * @param y translated y coordinate of the touch point + * @param pointerId pointer id touch point + * @param eventTime timestamp of touch point + */ + public void onMoveEvent(final int x, final int y, final int pointerId, final long eventTime); + + /** + * Process a down event on the more keys panel. + * + * @param x translated x coordinate of the touch point + * @param y translated y coordinate of the touch point + * @param pointerId pointer id touch point + * @param eventTime timestamp of touch point + */ + public void onDownEvent(final int x, final int y, final int pointerId, final long eventTime); + + /** + * Process an up event on the more keys panel. + * + * @param x translated x coordinate of the touch point + * @param y translated y coordinate of the touch point + * @param pointerId pointer id touch point + * @param eventTime timestamp of touch point + */ + public void onUpEvent(final int x, final int y, final int pointerId, final long eventTime); + + /** + * Translate X-coordinate of touch event to the local X-coordinate of this + * {@link MoreKeysPanel}. + * + * @param x the global X-coordinate + * @return the local X-coordinate to this {@link MoreKeysPanel} + */ + public int translateX(int x); + + /** + * Translate Y-coordinate of touch event to the local Y-coordinate of this + * {@link MoreKeysPanel}. + * + * @param y the global Y-coordinate + * @return the local Y-coordinate to this {@link MoreKeysPanel} + */ + public int translateY(int y); + + /** + * Show this {@link MoreKeysPanel} in the parent view. + * + * @param parentView the {@link ViewGroup} that hosts this {@link MoreKeysPanel}. + */ + public void showInParent(ViewGroup parentView); + + /** + * Remove this {@link MoreKeysPanel} from the parent view. + */ + public void removeFromParent(); + + /** + * Return whether the panel is currently being shown. + */ + public boolean isShowingInParent(); +} diff --git a/java/src/org/kelar/inputmethod/keyboard/PointerTracker.java b/java/src/org/kelar/inputmethod/keyboard/PointerTracker.java new file mode 100644 index 000000000..468f50775 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/PointerTracker.java @@ -0,0 +1,1198 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; + +import org.kelar.inputmethod.keyboard.internal.BatchInputArbiter; +import org.kelar.inputmethod.keyboard.internal.BatchInputArbiter.BatchInputArbiterListener; +import org.kelar.inputmethod.keyboard.internal.BogusMoveEventDetector; +import org.kelar.inputmethod.keyboard.internal.DrawingProxy; +import org.kelar.inputmethod.keyboard.internal.GestureEnabler; +import org.kelar.inputmethod.keyboard.internal.GestureStrokeDrawingParams; +import org.kelar.inputmethod.keyboard.internal.GestureStrokeDrawingPoints; +import org.kelar.inputmethod.keyboard.internal.GestureStrokeRecognitionParams; +import org.kelar.inputmethod.keyboard.internal.PointerTrackerQueue; +import org.kelar.inputmethod.keyboard.internal.TimerProxy; +import org.kelar.inputmethod.keyboard.internal.TypingTimeRecorder; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +import java.util.ArrayList; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class PointerTracker implements PointerTrackerQueue.Element, + BatchInputArbiterListener { + private static final String TAG = PointerTracker.class.getSimpleName(); + private static final boolean DEBUG_EVENT = false; + private static final boolean DEBUG_MOVE_EVENT = false; + private static final boolean DEBUG_LISTENER = false; + private static boolean DEBUG_MODE = DebugFlags.DEBUG_ENABLED || DEBUG_EVENT; + + static final class PointerTrackerParams { + public final boolean mKeySelectionByDraggingFinger; + public final int mTouchNoiseThresholdTime; + public final int mTouchNoiseThresholdDistance; + public final int mSuppressKeyPreviewAfterBatchInputDuration; + public final int mKeyRepeatStartTimeout; + public final int mKeyRepeatInterval; + public final int mLongPressShiftLockTimeout; + + public PointerTrackerParams(final TypedArray mainKeyboardViewAttr) { + mKeySelectionByDraggingFinger = mainKeyboardViewAttr.getBoolean( + R.styleable.MainKeyboardView_keySelectionByDraggingFinger, false); + mTouchNoiseThresholdTime = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_touchNoiseThresholdTime, 0); + mTouchNoiseThresholdDistance = mainKeyboardViewAttr.getDimensionPixelSize( + R.styleable.MainKeyboardView_touchNoiseThresholdDistance, 0); + mSuppressKeyPreviewAfterBatchInputDuration = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_suppressKeyPreviewAfterBatchInputDuration, 0); + mKeyRepeatStartTimeout = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_keyRepeatStartTimeout, 0); + mKeyRepeatInterval = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_keyRepeatInterval, 0); + mLongPressShiftLockTimeout = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_longPressShiftLockTimeout, 0); + } + } + + private static GestureEnabler sGestureEnabler = new GestureEnabler(); + + // Parameters for pointer handling. + private static PointerTrackerParams sParams; + private static GestureStrokeRecognitionParams sGestureStrokeRecognitionParams; + private static GestureStrokeDrawingParams sGestureStrokeDrawingParams; + private static boolean sNeedsPhantomSuddenMoveEventHack; + // Move this threshold to resource. + // TODO: Device specific parameter would be better for device specific hack? + private static final float PHANTOM_SUDDEN_MOVE_THRESHOLD = 0.25f; // in keyWidth + + private static final ArrayList<PointerTracker> sTrackers = new ArrayList<>(); + private static final PointerTrackerQueue sPointerTrackerQueue = new PointerTrackerQueue(); + + public final int mPointerId; + + private static DrawingProxy sDrawingProxy; + private static TimerProxy sTimerProxy; + private static KeyboardActionListener sListener = KeyboardActionListener.EMPTY_LISTENER; + + // The {@link KeyDetector} is set whenever the down event is processed. Also this is updated + // when new {@link Keyboard} is set by {@link #setKeyDetector(KeyDetector)}. + private KeyDetector mKeyDetector = new KeyDetector(); + private Keyboard mKeyboard; + private int mPhantomSuddenMoveThreshold; + private final BogusMoveEventDetector mBogusMoveEventDetector = new BogusMoveEventDetector(); + + private boolean mIsDetectingGesture = false; // per PointerTracker. + private static boolean sInGesture = false; + private static TypingTimeRecorder sTypingTimeRecorder; + + // The position and time at which first down event occurred. + private long mDownTime; + @Nonnull + private int[] mDownCoordinates = CoordinateUtils.newInstance(); + private long mUpTime; + + // The current key where this pointer is. + private Key mCurrentKey = null; + // The position where the current key was recognized for the first time. + private int mKeyX; + private int mKeyY; + + // Last pointer position. + private int mLastX; + private int mLastY; + + // true if keyboard layout has been changed. + private boolean mKeyboardLayoutHasBeenChanged; + + // true if this pointer is no longer triggering any action because it has been canceled. + private boolean mIsTrackingForActionDisabled; + + // the more keys panel currently being shown. equals null if no panel is active. + private MoreKeysPanel mMoreKeysPanel; + + private static final int MULTIPLIER_FOR_LONG_PRESS_TIMEOUT_IN_SLIDING_INPUT = 3; + // true if this pointer is in the dragging finger mode. + boolean mIsInDraggingFinger; + // true if this pointer is sliding from a modifier key and in the sliding key input mode, + // so that further modifier keys should be ignored. + boolean mIsInSlidingKeyInput; + // if not a NOT_A_CODE, the key of this code is repeating + private int mCurrentRepeatingKeyCode = Constants.NOT_A_CODE; + + // true if dragging finger is allowed. + private boolean mIsAllowedDraggingFinger; + + private final BatchInputArbiter mBatchInputArbiter; + private final GestureStrokeDrawingPoints mGestureStrokeDrawingPoints; + + // TODO: Add PointerTrackerFactory singleton and move some class static methods into it. + public static void init(final TypedArray mainKeyboardViewAttr, final TimerProxy timerProxy, + final DrawingProxy drawingProxy) { + sParams = new PointerTrackerParams(mainKeyboardViewAttr); + sGestureStrokeRecognitionParams = new GestureStrokeRecognitionParams(mainKeyboardViewAttr); + sGestureStrokeDrawingParams = new GestureStrokeDrawingParams(mainKeyboardViewAttr); + sTypingTimeRecorder = new TypingTimeRecorder( + sGestureStrokeRecognitionParams.mStaticTimeThresholdAfterFastTyping, + sParams.mSuppressKeyPreviewAfterBatchInputDuration); + + final Resources res = mainKeyboardViewAttr.getResources(); + sNeedsPhantomSuddenMoveEventHack = Boolean.parseBoolean( + ResourceUtils.getDeviceOverrideValue(res, + R.array.phantom_sudden_move_event_device_list, Boolean.FALSE.toString())); + BogusMoveEventDetector.init(res); + + sTimerProxy = timerProxy; + sDrawingProxy = drawingProxy; + } + + // Note that this method is called from a non-UI thread. + public static void setMainDictionaryAvailability(final boolean mainDictionaryAvailable) { + sGestureEnabler.setMainDictionaryAvailability(mainDictionaryAvailable); + } + + public static void setGestureHandlingEnabledByUser(final boolean gestureHandlingEnabledByUser) { + sGestureEnabler.setGestureHandlingEnabledByUser(gestureHandlingEnabledByUser); + } + + public static PointerTracker getPointerTracker(final int id) { + final ArrayList<PointerTracker> trackers = sTrackers; + + // Create pointer trackers until we can get 'id+1'-th tracker, if needed. + for (int i = trackers.size(); i <= id; i++) { + final PointerTracker tracker = new PointerTracker(i); + trackers.add(tracker); + } + + return trackers.get(id); + } + + public static boolean isAnyInDraggingFinger() { + return sPointerTrackerQueue.isAnyInDraggingFinger(); + } + + public static void cancelAllPointerTrackers() { + sPointerTrackerQueue.cancelAllPointerTrackers(); + } + + public static void setKeyboardActionListener(final KeyboardActionListener listener) { + sListener = listener; + } + + public static void setKeyDetector(final KeyDetector keyDetector) { + final Keyboard keyboard = keyDetector.getKeyboard(); + if (keyboard == null) { + return; + } + final int trackersSize = sTrackers.size(); + for (int i = 0; i < trackersSize; ++i) { + final PointerTracker tracker = sTrackers.get(i); + tracker.setKeyDetectorInner(keyDetector); + } + sGestureEnabler.setPasswordMode(keyboard.mId.passwordInput()); + } + + public static void setReleasedKeyGraphicsToAllKeys() { + final int trackersSize = sTrackers.size(); + for (int i = 0; i < trackersSize; ++i) { + final PointerTracker tracker = sTrackers.get(i); + tracker.setReleasedKeyGraphics(tracker.getKey(), true /* withAnimation */); + } + } + + public static void dismissAllMoreKeysPanels() { + final int trackersSize = sTrackers.size(); + for (int i = 0; i < trackersSize; ++i) { + final PointerTracker tracker = sTrackers.get(i); + tracker.dismissMoreKeysPanel(); + } + } + + private PointerTracker(final int id) { + mPointerId = id; + mBatchInputArbiter = new BatchInputArbiter(id, sGestureStrokeRecognitionParams); + mGestureStrokeDrawingPoints = new GestureStrokeDrawingPoints(sGestureStrokeDrawingParams); + } + + // Returns true if keyboard has been changed by this callback. + private boolean callListenerOnPressAndCheckKeyboardLayoutChange(final Key key, + final int repeatCount) { + // While gesture input is going on, this method should be a no-operation. But when gesture + // input has been canceled, <code>sInGesture</code> and <code>mIsDetectingGesture</code> + // are set to false. To keep this method is a no-operation, + // <code>mIsTrackingForActionDisabled</code> should also be taken account of. + if (sInGesture || mIsDetectingGesture || mIsTrackingForActionDisabled) { + return false; + } + final boolean ignoreModifierKey = mIsInDraggingFinger && key.isModifier(); + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onPress : %s%s%s%s", mPointerId, + (key == null ? "none" : Constants.printableCode(key.getCode())), + ignoreModifierKey ? " ignoreModifier" : "", + key.isEnabled() ? "" : " disabled", + repeatCount > 0 ? " repeatCount=" + repeatCount : "")); + } + if (ignoreModifierKey) { + return false; + } + if (key.isEnabled()) { + sListener.onPressKey(key.getCode(), repeatCount, getActivePointerTrackerCount() == 1); + final boolean keyboardLayoutHasBeenChanged = mKeyboardLayoutHasBeenChanged; + mKeyboardLayoutHasBeenChanged = false; + sTimerProxy.startTypingStateTimer(key); + return keyboardLayoutHasBeenChanged; + } + return false; + } + + // Note that we need primaryCode argument because the keyboard may in shifted state and the + // primaryCode is different from {@link Key#mKeyCode}. + private void callListenerOnCodeInput(final Key key, final int primaryCode, final int x, + final int y, final long eventTime, final boolean isKeyRepeat) { + final boolean ignoreModifierKey = mIsInDraggingFinger && key.isModifier(); + final boolean altersCode = key.altCodeWhileTyping() && sTimerProxy.isTypingState(); + final int code = altersCode ? key.getAltCode() : primaryCode; + if (DEBUG_LISTENER) { + final String output = code == Constants.CODE_OUTPUT_TEXT + ? key.getOutputText() : Constants.printableCode(code); + Log.d(TAG, String.format("[%d] onCodeInput: %4d %4d %s%s%s%s", mPointerId, x, y, + output, ignoreModifierKey ? " ignoreModifier" : "", + altersCode ? " altersCode" : "", key.isEnabled() ? "" : " disabled")); + } + if (ignoreModifierKey) { + return; + } + // Even if the key is disabled, it should respond if it is in the altCodeWhileTyping state. + if (key.isEnabled() || altersCode) { + sTypingTimeRecorder.onCodeInput(code, eventTime); + if (code == Constants.CODE_OUTPUT_TEXT) { + sListener.onTextInput(key.getOutputText()); + } else if (code != Constants.CODE_UNSPECIFIED) { + if (mKeyboard.hasProximityCharsCorrection(code)) { + sListener.onCodeInput(code, x, y, isKeyRepeat); + } else { + sListener.onCodeInput(code, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, isKeyRepeat); + } + } + } + } + + // Note that we need primaryCode argument because the keyboard may be in shifted state and the + // primaryCode is different from {@link Key#mKeyCode}. + private void callListenerOnRelease(final Key key, final int primaryCode, + final boolean withSliding) { + // See the comment at {@link #callListenerOnPressAndCheckKeyboardLayoutChange(Key}}. + if (sInGesture || mIsDetectingGesture || mIsTrackingForActionDisabled) { + return; + } + final boolean ignoreModifierKey = mIsInDraggingFinger && key.isModifier(); + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onRelease : %s%s%s%s", mPointerId, + Constants.printableCode(primaryCode), + withSliding ? " sliding" : "", ignoreModifierKey ? " ignoreModifier" : "", + key.isEnabled() ? "": " disabled")); + } + if (ignoreModifierKey) { + return; + } + if (key.isEnabled()) { + sListener.onReleaseKey(primaryCode, withSliding); + } + } + + private void callListenerOnFinishSlidingInput() { + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onFinishSlidingInput", mPointerId)); + } + sListener.onFinishSlidingInput(); + } + + private void callListenerOnCancelInput() { + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onCancelInput", mPointerId)); + } + sListener.onCancelInput(); + } + + private void setKeyDetectorInner(final KeyDetector keyDetector) { + final Keyboard keyboard = keyDetector.getKeyboard(); + if (keyboard == null) { + return; + } + if (keyDetector == mKeyDetector && keyboard == mKeyboard) { + return; + } + mKeyDetector = keyDetector; + mKeyboard = keyboard; + // Mark that keyboard layout has been changed. + mKeyboardLayoutHasBeenChanged = true; + final int keyWidth = mKeyboard.mMostCommonKeyWidth; + final int keyHeight = mKeyboard.mMostCommonKeyHeight; + mBatchInputArbiter.setKeyboardGeometry(keyWidth, mKeyboard.mOccupiedHeight); + // Keep {@link #mCurrentKey} that comes from previous keyboard. The key preview of + // {@link #mCurrentKey} will be dismissed by {@setReleasedKeyGraphics(Key)} via + // {@link onMoveEventInternal(int,int,long)} or {@link #onUpEventInternal(int,int,long)}. + mPhantomSuddenMoveThreshold = (int)(keyWidth * PHANTOM_SUDDEN_MOVE_THRESHOLD); + mBogusMoveEventDetector.setKeyboardGeometry(keyWidth, keyHeight); + } + + @Override + public boolean isInDraggingFinger() { + return mIsInDraggingFinger; + } + + @Nullable + public Key getKey() { + return mCurrentKey; + } + + @Override + public boolean isModifier() { + return mCurrentKey != null && mCurrentKey.isModifier(); + } + + public Key getKeyOn(final int x, final int y) { + return mKeyDetector.detectHitKey(x, y); + } + + private void setReleasedKeyGraphics(@Nullable final Key key, final boolean withAnimation) { + if (key == null) { + return; + } + + sDrawingProxy.onKeyReleased(key, withAnimation); + + if (key.isShift()) { + for (final Key shiftKey : mKeyboard.mShiftKeys) { + if (shiftKey != key) { + sDrawingProxy.onKeyReleased(shiftKey, false /* withAnimation */); + } + } + } + + if (key.altCodeWhileTyping()) { + final int altCode = key.getAltCode(); + final Key altKey = mKeyboard.getKey(altCode); + if (altKey != null) { + sDrawingProxy.onKeyReleased(altKey, false /* withAnimation */); + } + for (final Key k : mKeyboard.mAltCodeKeysWhileTyping) { + if (k != key && k.getAltCode() == altCode) { + sDrawingProxy.onKeyReleased(k, false /* withAnimation */); + } + } + } + } + + private static boolean needsToSuppressKeyPreviewPopup(final long eventTime) { + if (!sGestureEnabler.shouldHandleGesture()) return false; + return sTypingTimeRecorder.needsToSuppressKeyPreviewPopup(eventTime); + } + + private void setPressedKeyGraphics(@Nullable final Key key, final long eventTime) { + if (key == null) { + return; + } + + // Even if the key is disabled, it should respond if it is in the altCodeWhileTyping state. + final boolean altersCode = key.altCodeWhileTyping() && sTimerProxy.isTypingState(); + final boolean needsToUpdateGraphics = key.isEnabled() || altersCode; + if (!needsToUpdateGraphics) { + return; + } + + final boolean noKeyPreview = sInGesture || needsToSuppressKeyPreviewPopup(eventTime); + sDrawingProxy.onKeyPressed(key, !noKeyPreview); + + if (key.isShift()) { + for (final Key shiftKey : mKeyboard.mShiftKeys) { + if (shiftKey != key) { + sDrawingProxy.onKeyPressed(shiftKey, false /* withPreview */); + } + } + } + + if (altersCode) { + final int altCode = key.getAltCode(); + final Key altKey = mKeyboard.getKey(altCode); + if (altKey != null) { + sDrawingProxy.onKeyPressed(altKey, false /* withPreview */); + } + for (final Key k : mKeyboard.mAltCodeKeysWhileTyping) { + if (k != key && k.getAltCode() == altCode) { + sDrawingProxy.onKeyPressed(k, false /* withPreview */); + } + } + } + } + + public GestureStrokeDrawingPoints getGestureStrokeDrawingPoints() { + return mGestureStrokeDrawingPoints; + } + + public void getLastCoordinates(@Nonnull final int[] outCoords) { + CoordinateUtils.set(outCoords, mLastX, mLastY); + } + + public long getDownTime() { + return mDownTime; + } + + public void getDownCoordinates(@Nonnull final int[] outCoords) { + CoordinateUtils.copy(outCoords, mDownCoordinates); + } + + private Key onDownKey(final int x, final int y, final long eventTime) { + mDownTime = eventTime; + CoordinateUtils.set(mDownCoordinates, x, y); + mBogusMoveEventDetector.onDownKey(); + return onMoveToNewKey(onMoveKeyInternal(x, y), x, y); + } + + private static int getDistance(final int x1, final int y1, final int x2, final int y2) { + return (int)Math.hypot(x1 - x2, y1 - y2); + } + + private Key onMoveKeyInternal(final int x, final int y) { + mBogusMoveEventDetector.onMoveKey(getDistance(x, y, mLastX, mLastY)); + mLastX = x; + mLastY = y; + return mKeyDetector.detectHitKey(x, y); + } + + private Key onMoveKey(final int x, final int y) { + return onMoveKeyInternal(x, y); + } + + private Key onMoveToNewKey(final Key newKey, final int x, final int y) { + mCurrentKey = newKey; + mKeyX = x; + mKeyY = y; + return newKey; + } + + /* package */ static int getActivePointerTrackerCount() { + return sPointerTrackerQueue.size(); + } + + private boolean isOldestTrackerInQueue() { + return sPointerTrackerQueue.getOldestElement() == this; + } + + // Implements {@link BatchInputArbiterListener}. + @Override + public void onStartBatchInput() { + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onStartBatchInput", mPointerId)); + } + sListener.onStartBatchInput(); + dismissAllMoreKeysPanels(); + sTimerProxy.cancelLongPressTimersOf(this); + } + + private void showGestureTrail() { + if (mIsTrackingForActionDisabled) { + return; + } + // A gesture floating preview text will be shown at the oldest pointer/finger on the screen. + sDrawingProxy.showGestureTrail( + this, isOldestTrackerInQueue() /* showsFloatingPreviewText */); + } + + public void updateBatchInputByTimer(final long syntheticMoveEventTime) { + mBatchInputArbiter.updateBatchInputByTimer(syntheticMoveEventTime, this); + } + + // Implements {@link BatchInputArbiterListener}. + @Override + public void onUpdateBatchInput(final InputPointers aggregatedPointers, final long eventTime) { + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onUpdateBatchInput: batchPoints=%d", mPointerId, + aggregatedPointers.getPointerSize())); + } + sListener.onUpdateBatchInput(aggregatedPointers); + } + + // Implements {@link BatchInputArbiterListener}. + @Override + public void onStartUpdateBatchInputTimer() { + sTimerProxy.startUpdateBatchInputTimer(this); + } + + // Implements {@link BatchInputArbiterListener}. + @Override + public void onEndBatchInput(final InputPointers aggregatedPointers, final long eventTime) { + sTypingTimeRecorder.onEndBatchInput(eventTime); + sTimerProxy.cancelAllUpdateBatchInputTimers(); + if (mIsTrackingForActionDisabled) { + return; + } + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onEndBatchInput : batchPoints=%d", + mPointerId, aggregatedPointers.getPointerSize())); + } + sListener.onEndBatchInput(aggregatedPointers); + } + + private void cancelBatchInput() { + cancelAllPointerTrackers(); + mIsDetectingGesture = false; + if (!sInGesture) { + return; + } + sInGesture = false; + if (DEBUG_LISTENER) { + Log.d(TAG, String.format("[%d] onCancelBatchInput", mPointerId)); + } + sListener.onCancelBatchInput(); + } + + public void processMotionEvent(final MotionEvent me, final KeyDetector keyDetector) { + final int action = me.getActionMasked(); + final long eventTime = me.getEventTime(); + if (action == MotionEvent.ACTION_MOVE) { + // When this pointer is the only active pointer and is showing a more keys panel, + // we should ignore other pointers' motion event. + final boolean shouldIgnoreOtherPointers = + isShowingMoreKeysPanel() && getActivePointerTrackerCount() == 1; + final int pointerCount = me.getPointerCount(); + for (int index = 0; index < pointerCount; index++) { + final int id = me.getPointerId(index); + if (shouldIgnoreOtherPointers && id != mPointerId) { + continue; + } + final int x = (int)me.getX(index); + final int y = (int)me.getY(index); + final PointerTracker tracker = getPointerTracker(id); + tracker.onMoveEvent(x, y, eventTime, me); + } + return; + } + final int index = me.getActionIndex(); + final int x = (int)me.getX(index); + final int y = (int)me.getY(index); + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + onDownEvent(x, y, eventTime, keyDetector); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + onUpEvent(x, y, eventTime); + break; + case MotionEvent.ACTION_CANCEL: + onCancelEvent(x, y, eventTime); + break; + } + } + + private void onDownEvent(final int x, final int y, final long eventTime, + final KeyDetector keyDetector) { + setKeyDetectorInner(keyDetector); + if (DEBUG_EVENT) { + printTouchEvent("onDownEvent:", x, y, eventTime); + } + // Naive up-to-down noise filter. + final long deltaT = eventTime - mUpTime; + if (deltaT < sParams.mTouchNoiseThresholdTime) { + final int distance = getDistance(x, y, mLastX, mLastY); + if (distance < sParams.mTouchNoiseThresholdDistance) { + if (DEBUG_MODE) + Log.w(TAG, String.format("[%d] onDownEvent:" + + " ignore potential noise: time=%d distance=%d", + mPointerId, deltaT, distance)); + cancelTrackingForAction(); + return; + } + } + + final Key key = getKeyOn(x, y); + mBogusMoveEventDetector.onActualDownEvent(x, y); + if (key != null && key.isModifier()) { + // Before processing a down event of modifier key, all pointers already being + // tracked should be released. + sPointerTrackerQueue.releaseAllPointers(eventTime); + } + sPointerTrackerQueue.add(this); + onDownEventInternal(x, y, eventTime); + if (!sGestureEnabler.shouldHandleGesture()) { + return; + } + // A gesture should start only from a non-modifier key. Note that the gesture detection is + // disabled when the key is repeating. + mIsDetectingGesture = (mKeyboard != null) && mKeyboard.mId.isAlphabetKeyboard() + && key != null && !key.isModifier(); + if (mIsDetectingGesture) { + mBatchInputArbiter.addDownEventPoint(x, y, eventTime, + sTypingTimeRecorder.getLastLetterTypingTime(), getActivePointerTrackerCount()); + mGestureStrokeDrawingPoints.onDownEvent( + x, y, mBatchInputArbiter.getElapsedTimeSinceFirstDown(eventTime)); + } + } + + /* package */ boolean isShowingMoreKeysPanel() { + return (mMoreKeysPanel != null); + } + + private void dismissMoreKeysPanel() { + if (isShowingMoreKeysPanel()) { + mMoreKeysPanel.dismissMoreKeysPanel(); + mMoreKeysPanel = null; + } + } + + private void onDownEventInternal(final int x, final int y, final long eventTime) { + Key key = onDownKey(x, y, eventTime); + // Key selection by dragging finger is allowed when 1) key selection by dragging finger is + // enabled by configuration, 2) this pointer starts dragging from modifier key, or 3) this + // pointer's KeyDetector always allows key selection by dragging finger, such as + // {@link MoreKeysKeyboard}. + mIsAllowedDraggingFinger = sParams.mKeySelectionByDraggingFinger + || (key != null && key.isModifier()) + || mKeyDetector.alwaysAllowsKeySelectionByDraggingFinger(); + mKeyboardLayoutHasBeenChanged = false; + mIsTrackingForActionDisabled = false; + resetKeySelectionByDraggingFinger(); + if (key != null) { + // This onPress call may have changed keyboard layout. Those cases are detected at + // {@link #setKeyboard}. In those cases, we should update key according to the new + // keyboard layout. + if (callListenerOnPressAndCheckKeyboardLayoutChange(key, 0 /* repeatCount */)) { + key = onDownKey(x, y, eventTime); + } + + startRepeatKey(key); + startLongPressTimer(key); + setPressedKeyGraphics(key, eventTime); + } + } + + private void startKeySelectionByDraggingFinger(final Key key) { + if (!mIsInDraggingFinger) { + mIsInSlidingKeyInput = key.isModifier(); + } + mIsInDraggingFinger = true; + } + + private void resetKeySelectionByDraggingFinger() { + mIsInDraggingFinger = false; + mIsInSlidingKeyInput = false; + sDrawingProxy.showSlidingKeyInputPreview(null /* tracker */); + } + + private void onGestureMoveEvent(final int x, final int y, final long eventTime, + final boolean isMajorEvent, final Key key) { + if (!mIsDetectingGesture) { + return; + } + final boolean onValidArea = mBatchInputArbiter.addMoveEventPoint( + x, y, eventTime, isMajorEvent, this); + // If the move event goes out from valid batch input area, cancel batch input. + if (!onValidArea) { + cancelBatchInput(); + return; + } + mGestureStrokeDrawingPoints.onMoveEvent( + x, y, mBatchInputArbiter.getElapsedTimeSinceFirstDown(eventTime)); + // If the MoreKeysPanel is showing then do not attempt to enter gesture mode. However, + // the gestured touch points are still being recorded in case the panel is dismissed. + if (isShowingMoreKeysPanel()) { + return; + } + if (!sInGesture && key != null && Character.isLetter(key.getCode()) + && mBatchInputArbiter.mayStartBatchInput(this)) { + sInGesture = true; + } + if (sInGesture) { + if (key != null) { + mBatchInputArbiter.updateBatchInput(eventTime, this); + } + showGestureTrail(); + } + } + + private void onMoveEvent(final int x, final int y, final long eventTime, final MotionEvent me) { + if (DEBUG_MOVE_EVENT) { + printTouchEvent("onMoveEvent:", x, y, eventTime); + } + if (mIsTrackingForActionDisabled) { + return; + } + + if (sGestureEnabler.shouldHandleGesture() && me != null) { + // Add historical points to gesture path. + final int pointerIndex = me.findPointerIndex(mPointerId); + final int historicalSize = me.getHistorySize(); + for (int h = 0; h < historicalSize; h++) { + final int historicalX = (int)me.getHistoricalX(pointerIndex, h); + final int historicalY = (int)me.getHistoricalY(pointerIndex, h); + final long historicalTime = me.getHistoricalEventTime(h); + onGestureMoveEvent(historicalX, historicalY, historicalTime, + false /* isMajorEvent */, null); + } + } + + if (isShowingMoreKeysPanel()) { + final int translatedX = mMoreKeysPanel.translateX(x); + final int translatedY = mMoreKeysPanel.translateY(y); + mMoreKeysPanel.onMoveEvent(translatedX, translatedY, mPointerId, eventTime); + onMoveKey(x, y); + if (mIsInSlidingKeyInput) { + sDrawingProxy.showSlidingKeyInputPreview(this); + } + return; + } + onMoveEventInternal(x, y, eventTime); + } + + private void processDraggingFingerInToNewKey(final Key newKey, final int x, final int y, + final long eventTime) { + // This onPress call may have changed keyboard layout. Those cases are detected + // at {@link #setKeyboard}. In those cases, we should update key according + // to the new keyboard layout. + Key key = newKey; + if (callListenerOnPressAndCheckKeyboardLayoutChange(key, 0 /* repeatCount */)) { + key = onMoveKey(x, y); + } + onMoveToNewKey(key, x, y); + if (mIsTrackingForActionDisabled) { + return; + } + startLongPressTimer(key); + setPressedKeyGraphics(key, eventTime); + } + + private void processPhantomSuddenMoveHack(final Key key, final int x, final int y, + final long eventTime, final Key oldKey, final int lastX, final int lastY) { + if (DEBUG_MODE) { + Log.w(TAG, String.format("[%d] onMoveEvent:" + + " phantom sudden move event (distance=%d) is translated to " + + "up[%d,%d,%s]/down[%d,%d,%s] events", mPointerId, + getDistance(x, y, lastX, lastY), + lastX, lastY, Constants.printableCode(oldKey.getCode()), + x, y, Constants.printableCode(key.getCode()))); + } + onUpEventInternal(x, y, eventTime); + onDownEventInternal(x, y, eventTime); + } + + private void processProximateBogusDownMoveUpEventHack(final Key key, final int x, final int y, + final long eventTime, final Key oldKey, final int lastX, final int lastY) { + if (DEBUG_MODE) { + final float keyDiagonal = (float)Math.hypot( + mKeyboard.mMostCommonKeyWidth, mKeyboard.mMostCommonKeyHeight); + final float radiusRatio = + mBogusMoveEventDetector.getDistanceFromDownEvent(x, y) + / keyDiagonal; + Log.w(TAG, String.format("[%d] onMoveEvent:" + + " bogus down-move-up event (raidus=%.2f key diagonal) is " + + " translated to up[%d,%d,%s]/down[%d,%d,%s] events", + mPointerId, radiusRatio, + lastX, lastY, Constants.printableCode(oldKey.getCode()), + x, y, Constants.printableCode(key.getCode()))); + } + onUpEventInternal(x, y, eventTime); + onDownEventInternal(x, y, eventTime); + } + + private void processDraggingFingerOutFromOldKey(final Key oldKey) { + setReleasedKeyGraphics(oldKey, true /* withAnimation */); + callListenerOnRelease(oldKey, oldKey.getCode(), true /* withSliding */); + startKeySelectionByDraggingFinger(oldKey); + sTimerProxy.cancelKeyTimersOf(this); + } + + private void dragFingerFromOldKeyToNewKey(final Key key, final int x, final int y, + final long eventTime, final Key oldKey, final int lastX, final int lastY) { + // The pointer has been slid in to the new key from the previous key, we must call + // onRelease() first to notify that the previous key has been released, then call + // onPress() to notify that the new key is being pressed. + processDraggingFingerOutFromOldKey(oldKey); + startRepeatKey(key); + if (mIsAllowedDraggingFinger) { + processDraggingFingerInToNewKey(key, x, y, eventTime); + } + // HACK: On some devices, quick successive touches may be reported as a sudden move by + // touch panel firmware. This hack detects such cases and translates the move event to + // successive up and down events. + // TODO: Should find a way to balance gesture detection and this hack. + else if (sNeedsPhantomSuddenMoveEventHack + && getDistance(x, y, lastX, lastY) >= mPhantomSuddenMoveThreshold) { + processPhantomSuddenMoveHack(key, x, y, eventTime, oldKey, lastX, lastY); + } + // HACK: On some devices, quick successive proximate touches may be reported as a bogus + // down-move-up event by touch panel firmware. This hack detects such cases and breaks + // these events into separate up and down events. + else if (sTypingTimeRecorder.isInFastTyping(eventTime) + && mBogusMoveEventDetector.isCloseToActualDownEvent(x, y)) { + processProximateBogusDownMoveUpEventHack(key, x, y, eventTime, oldKey, lastX, lastY); + } + // HACK: If there are currently multiple touches, register the key even if the finger + // slides off the key. This defends against noise from some touch panels when there are + // close multiple touches. + // Caveat: When in chording input mode with a modifier key, we don't use this hack. + else if (getActivePointerTrackerCount() > 1 + && !sPointerTrackerQueue.hasModifierKeyOlderThan(this)) { + if (DEBUG_MODE) { + Log.w(TAG, String.format("[%d] onMoveEvent:" + + " detected sliding finger while multi touching", mPointerId)); + } + onUpEvent(x, y, eventTime); + cancelTrackingForAction(); + setReleasedKeyGraphics(oldKey, true /* withAnimation */); + } else { + if (!mIsDetectingGesture) { + cancelTrackingForAction(); + } + setReleasedKeyGraphics(oldKey, true /* withAnimation */); + } + } + + private void dragFingerOutFromOldKey(final Key oldKey, final int x, final int y) { + // The pointer has been slid out from the previous key, we must call onRelease() to + // notify that the previous key has been released. + processDraggingFingerOutFromOldKey(oldKey); + if (mIsAllowedDraggingFinger) { + onMoveToNewKey(null, x, y); + } else { + if (!mIsDetectingGesture) { + cancelTrackingForAction(); + } + } + } + + private void onMoveEventInternal(final int x, final int y, final long eventTime) { + final int lastX = mLastX; + final int lastY = mLastY; + final Key oldKey = mCurrentKey; + final Key newKey = onMoveKey(x, y); + + if (sGestureEnabler.shouldHandleGesture()) { + // Register move event on gesture tracker. + onGestureMoveEvent(x, y, eventTime, true /* isMajorEvent */, newKey); + if (sInGesture) { + mCurrentKey = null; + setReleasedKeyGraphics(oldKey, true /* withAnimation */); + return; + } + } + + if (newKey != null) { + if (oldKey != null && isMajorEnoughMoveToBeOnNewKey(x, y, eventTime, newKey)) { + dragFingerFromOldKeyToNewKey(newKey, x, y, eventTime, oldKey, lastX, lastY); + } else if (oldKey == null) { + // The pointer has been slid in to the new key, but the finger was not on any keys. + // In this case, we must call onPress() to notify that the new key is being pressed. + processDraggingFingerInToNewKey(newKey, x, y, eventTime); + } + } else { // newKey == null + if (oldKey != null && isMajorEnoughMoveToBeOnNewKey(x, y, eventTime, newKey)) { + dragFingerOutFromOldKey(oldKey, x, y); + } + } + if (mIsInSlidingKeyInput) { + sDrawingProxy.showSlidingKeyInputPreview(this); + } + } + + private void onUpEvent(final int x, final int y, final long eventTime) { + if (DEBUG_EVENT) { + printTouchEvent("onUpEvent :", x, y, eventTime); + } + + sTimerProxy.cancelUpdateBatchInputTimer(this); + if (!sInGesture) { + if (mCurrentKey != null && mCurrentKey.isModifier()) { + // Before processing an up event of modifier key, all pointers already being + // tracked should be released. + sPointerTrackerQueue.releaseAllPointersExcept(this, eventTime); + } else { + sPointerTrackerQueue.releaseAllPointersOlderThan(this, eventTime); + } + } + onUpEventInternal(x, y, eventTime); + sPointerTrackerQueue.remove(this); + } + + // Let this pointer tracker know that one of newer-than-this pointer trackers got an up event. + // This pointer tracker needs to keep the key top graphics "pressed", but needs to get a + // "virtual" up event. + @Override + public void onPhantomUpEvent(final long eventTime) { + if (DEBUG_EVENT) { + printTouchEvent("onPhntEvent:", mLastX, mLastY, eventTime); + } + onUpEventInternal(mLastX, mLastY, eventTime); + cancelTrackingForAction(); + } + + private void onUpEventInternal(final int x, final int y, final long eventTime) { + sTimerProxy.cancelKeyTimersOf(this); + final boolean isInDraggingFinger = mIsInDraggingFinger; + final boolean isInSlidingKeyInput = mIsInSlidingKeyInput; + resetKeySelectionByDraggingFinger(); + mIsDetectingGesture = false; + final Key currentKey = mCurrentKey; + mCurrentKey = null; + final int currentRepeatingKeyCode = mCurrentRepeatingKeyCode; + mCurrentRepeatingKeyCode = Constants.NOT_A_CODE; + // Release the last pressed key. + setReleasedKeyGraphics(currentKey, true /* withAnimation */); + + if (isShowingMoreKeysPanel()) { + if (!mIsTrackingForActionDisabled) { + final int translatedX = mMoreKeysPanel.translateX(x); + final int translatedY = mMoreKeysPanel.translateY(y); + mMoreKeysPanel.onUpEvent(translatedX, translatedY, mPointerId, eventTime); + } + dismissMoreKeysPanel(); + return; + } + + if (sInGesture) { + if (currentKey != null) { + callListenerOnRelease(currentKey, currentKey.getCode(), true /* withSliding */); + } + if (mBatchInputArbiter.mayEndBatchInput( + eventTime, getActivePointerTrackerCount(), this)) { + sInGesture = false; + } + showGestureTrail(); + return; + } + + if (mIsTrackingForActionDisabled) { + return; + } + if (currentKey != null && currentKey.isRepeatable() + && (currentKey.getCode() == currentRepeatingKeyCode) && !isInDraggingFinger) { + return; + } + detectAndSendKey(currentKey, mKeyX, mKeyY, eventTime); + if (isInSlidingKeyInput) { + callListenerOnFinishSlidingInput(); + } + } + + @Override + public void cancelTrackingForAction() { + if (isShowingMoreKeysPanel()) { + return; + } + mIsTrackingForActionDisabled = true; + } + + public boolean isInOperation() { + return !mIsTrackingForActionDisabled; + } + + public void onLongPressed() { + sTimerProxy.cancelLongPressTimersOf(this); + if (isShowingMoreKeysPanel()) { + return; + } + final Key key = getKey(); + if (key == null) { + return; + } + if (key.hasNoPanelAutoMoreKey()) { + cancelKeyTracking(); + final int moreKeyCode = key.getMoreKeys()[0].mCode; + sListener.onPressKey(moreKeyCode, 0 /* repeatCont */, true /* isSinglePointer */); + sListener.onCodeInput(moreKeyCode, Constants.NOT_A_COORDINATE, + Constants.NOT_A_COORDINATE, false /* isKeyRepeat */); + sListener.onReleaseKey(moreKeyCode, false /* withSliding */); + return; + } + final int code = key.getCode(); + if (code == Constants.CODE_SPACE || code == Constants.CODE_LANGUAGE_SWITCH) { + // Long pressing the space key invokes IME switcher dialog. + if (sListener.onCustomRequest(Constants.CUSTOM_CODE_SHOW_INPUT_METHOD_PICKER)) { + cancelKeyTracking(); + sListener.onReleaseKey(code, false /* withSliding */); + return; + } + } + + setReleasedKeyGraphics(key, false /* withAnimation */); + final MoreKeysPanel moreKeysPanel = sDrawingProxy.showMoreKeysKeyboard(key, this); + if (moreKeysPanel == null) { + return; + } + final int translatedX = moreKeysPanel.translateX(mLastX); + final int translatedY = moreKeysPanel.translateY(mLastY); + moreKeysPanel.onDownEvent(translatedX, translatedY, mPointerId, SystemClock.uptimeMillis()); + mMoreKeysPanel = moreKeysPanel; + } + + private void cancelKeyTracking() { + resetKeySelectionByDraggingFinger(); + cancelTrackingForAction(); + setReleasedKeyGraphics(mCurrentKey, true /* withAnimation */); + sPointerTrackerQueue.remove(this); + } + + private void onCancelEvent(final int x, final int y, final long eventTime) { + if (DEBUG_EVENT) { + printTouchEvent("onCancelEvt:", x, y, eventTime); + } + + cancelBatchInput(); + cancelAllPointerTrackers(); + sPointerTrackerQueue.releaseAllPointers(eventTime); + onCancelEventInternal(); + } + + private void onCancelEventInternal() { + sTimerProxy.cancelKeyTimersOf(this); + setReleasedKeyGraphics(mCurrentKey, true /* withAnimation */); + resetKeySelectionByDraggingFinger(); + dismissMoreKeysPanel(); + } + + private boolean isMajorEnoughMoveToBeOnNewKey(final int x, final int y, final long eventTime, + final Key newKey) { + final Key curKey = mCurrentKey; + if (newKey == curKey) { + return false; + } + if (curKey == null /* && newKey != null */) { + return true; + } + // Here curKey points to the different key from newKey. + final int keyHysteresisDistanceSquared = mKeyDetector.getKeyHysteresisDistanceSquared( + mIsInSlidingKeyInput); + final int distanceFromKeyEdgeSquared = curKey.squaredDistanceToEdge(x, y); + if (distanceFromKeyEdgeSquared >= keyHysteresisDistanceSquared) { + if (DEBUG_MODE) { + final float distanceToEdgeRatio = (float)Math.sqrt(distanceFromKeyEdgeSquared) + / mKeyboard.mMostCommonKeyWidth; + Log.d(TAG, String.format("[%d] isMajorEnoughMoveToBeOnNewKey:" + +" %.2f key width from key edge", mPointerId, distanceToEdgeRatio)); + } + return true; + } + if (!mIsAllowedDraggingFinger && sTypingTimeRecorder.isInFastTyping(eventTime) + && mBogusMoveEventDetector.hasTraveledLongDistance(x, y)) { + if (DEBUG_MODE) { + final float keyDiagonal = (float)Math.hypot( + mKeyboard.mMostCommonKeyWidth, mKeyboard.mMostCommonKeyHeight); + final float lengthFromDownRatio = + mBogusMoveEventDetector.getAccumulatedDistanceFromDownKey() / keyDiagonal; + Log.d(TAG, String.format("[%d] isMajorEnoughMoveToBeOnNewKey:" + + " %.2f key diagonal from virtual down point", + mPointerId, lengthFromDownRatio)); + } + return true; + } + return false; + } + + private void startLongPressTimer(final Key key) { + // Note that we need to cancel all active long press shift key timers if any whenever we + // start a new long press timer for both non-shift and shift keys. + sTimerProxy.cancelLongPressShiftKeyTimer(); + if (sInGesture) return; + if (key == null) return; + if (!key.isLongPressEnabled()) return; + // Caveat: Please note that isLongPressEnabled() can be true even if the current key + // doesn't have its more keys. (e.g. spacebar, globe key) If we are in the dragging finger + // mode, we will disable long press timer of such key. + // We always need to start the long press timer if the key has its more keys regardless of + // whether or not we are in the dragging finger mode. + if (mIsInDraggingFinger && key.getMoreKeys() == null) return; + + final int delay = getLongPressTimeout(key.getCode()); + if (delay <= 0) return; + sTimerProxy.startLongPressTimerOf(this, delay); + } + + private int getLongPressTimeout(final int code) { + if (code == Constants.CODE_SHIFT) { + return sParams.mLongPressShiftLockTimeout; + } + final int longpressTimeout = Settings.getInstance().getCurrent().mKeyLongpressTimeout; + if (mIsInSlidingKeyInput) { + // We use longer timeout for sliding finger input started from the modifier key. + return longpressTimeout * MULTIPLIER_FOR_LONG_PRESS_TIMEOUT_IN_SLIDING_INPUT; + } + return longpressTimeout; + } + + private void detectAndSendKey(final Key key, final int x, final int y, final long eventTime) { + if (key == null) { + callListenerOnCancelInput(); + return; + } + + final int code = key.getCode(); + callListenerOnCodeInput(key, code, x, y, eventTime, false /* isKeyRepeat */); + callListenerOnRelease(key, code, false /* withSliding */); + } + + private void startRepeatKey(final Key key) { + if (sInGesture) return; + if (key == null) return; + if (!key.isRepeatable()) return; + // Don't start key repeat when we are in the dragging finger mode. + if (mIsInDraggingFinger) return; + final int startRepeatCount = 1; + startKeyRepeatTimer(startRepeatCount); + } + + public void onKeyRepeat(final int code, final int repeatCount) { + final Key key = getKey(); + if (key == null || key.getCode() != code) { + mCurrentRepeatingKeyCode = Constants.NOT_A_CODE; + return; + } + mCurrentRepeatingKeyCode = code; + mIsDetectingGesture = false; + final int nextRepeatCount = repeatCount + 1; + startKeyRepeatTimer(nextRepeatCount); + callListenerOnPressAndCheckKeyboardLayoutChange(key, repeatCount); + callListenerOnCodeInput(key, code, mKeyX, mKeyY, SystemClock.uptimeMillis(), + true /* isKeyRepeat */); + } + + private void startKeyRepeatTimer(final int repeatCount) { + final int delay = + (repeatCount == 1) ? sParams.mKeyRepeatStartTimeout : sParams.mKeyRepeatInterval; + sTimerProxy.startKeyRepeatTimerOf(this, repeatCount, delay); + } + + private void printTouchEvent(final String title, final int x, final int y, + final long eventTime) { + final Key key = mKeyDetector.detectHitKey(x, y); + final String code = (key == null ? "none" : Constants.printableCode(key.getCode())); + Log.d(TAG, String.format("[%d]%s%s %4d %4d %5d %s", mPointerId, + (mIsTrackingForActionDisabled ? "-" : " "), title, x, y, eventTime, code)); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/ProximityInfo.java b/java/src/org/kelar/inputmethod/keyboard/ProximityInfo.java new file mode 100644 index 000000000..62eca07ea --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/ProximityInfo.java @@ -0,0 +1,405 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard; + +import android.graphics.Rect; +import android.util.Log; + +import org.kelar.inputmethod.keyboard.internal.TouchPositionCorrection; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.JniUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +public class ProximityInfo { + private static final String TAG = ProximityInfo.class.getSimpleName(); + private static final boolean DEBUG = false; + + // Must be equal to MAX_PROXIMITY_CHARS_SIZE in native/jni/src/defines.h + public static final int MAX_PROXIMITY_CHARS_SIZE = 16; + /** Number of key widths from current touch point to search for nearest keys. */ + private static final float SEARCH_DISTANCE = 1.2f; + @Nonnull + private static final List<Key> EMPTY_KEY_LIST = Collections.emptyList(); + private static final float DEFAULT_TOUCH_POSITION_CORRECTION_RADIUS = 0.15f; + + private final int mGridWidth; + private final int mGridHeight; + private final int mGridSize; + private final int mCellWidth; + private final int mCellHeight; + // TODO: Find a proper name for mKeyboardMinWidth + private final int mKeyboardMinWidth; + private final int mKeyboardHeight; + private final int mMostCommonKeyWidth; + private final int mMostCommonKeyHeight; + @Nonnull + private final List<Key> mSortedKeys; + @Nonnull + private final List<Key>[] mGridNeighbors; + + @SuppressWarnings("unchecked") + ProximityInfo(final int gridWidth, final int gridHeight, final int minWidth, final int height, + final int mostCommonKeyWidth, final int mostCommonKeyHeight, + @Nonnull final List<Key> sortedKeys, + @Nonnull final TouchPositionCorrection touchPositionCorrection) { + mGridWidth = gridWidth; + mGridHeight = gridHeight; + mGridSize = mGridWidth * mGridHeight; + mCellWidth = (minWidth + mGridWidth - 1) / mGridWidth; + mCellHeight = (height + mGridHeight - 1) / mGridHeight; + mKeyboardMinWidth = minWidth; + mKeyboardHeight = height; + mMostCommonKeyHeight = mostCommonKeyHeight; + mMostCommonKeyWidth = mostCommonKeyWidth; + mSortedKeys = sortedKeys; + mGridNeighbors = new List[mGridSize]; + if (minWidth == 0 || height == 0) { + // No proximity required. Keyboard might be more keys keyboard. + return; + } + computeNearestNeighbors(); + mNativeProximityInfo = createNativeProximityInfo(touchPositionCorrection); + } + + private long mNativeProximityInfo; + static { + JniUtils.loadNativeLibrary(); + } + + // TODO: Stop passing proximityCharsArray + private static native long setProximityInfoNative(int displayWidth, int displayHeight, + int gridWidth, int gridHeight, int mostCommonKeyWidth, int mostCommonKeyHeight, + int[] proximityCharsArray, int keyCount, int[] keyXCoordinates, int[] keyYCoordinates, + int[] keyWidths, int[] keyHeights, int[] keyCharCodes, float[] sweetSpotCenterXs, + float[] sweetSpotCenterYs, float[] sweetSpotRadii); + + private static native void releaseProximityInfoNative(long nativeProximityInfo); + + static boolean needsProximityInfo(final Key key) { + // Don't include special keys into ProximityInfo. + return key.getCode() >= Constants.CODE_SPACE; + } + + private static int getProximityInfoKeysCount(final List<Key> keys) { + int count = 0; + for (final Key key : keys) { + if (needsProximityInfo(key)) { + count++; + } + } + return count; + } + + private long createNativeProximityInfo( + @Nonnull final TouchPositionCorrection touchPositionCorrection) { + final List<Key>[] gridNeighborKeys = mGridNeighbors; + final int[] proximityCharsArray = new int[mGridSize * MAX_PROXIMITY_CHARS_SIZE]; + Arrays.fill(proximityCharsArray, Constants.NOT_A_CODE); + for (int i = 0; i < mGridSize; ++i) { + final List<Key> neighborKeys = gridNeighborKeys[i]; + final int proximityCharsLength = neighborKeys.size(); + int infoIndex = i * MAX_PROXIMITY_CHARS_SIZE; + for (int j = 0; j < proximityCharsLength; ++j) { + final Key neighborKey = neighborKeys.get(j); + // Excluding from proximityCharsArray + if (!needsProximityInfo(neighborKey)) { + continue; + } + proximityCharsArray[infoIndex] = neighborKey.getCode(); + infoIndex++; + } + } + if (DEBUG) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < mGridSize; i++) { + sb.setLength(0); + for (int j = 0; j < MAX_PROXIMITY_CHARS_SIZE; j++) { + final int code = proximityCharsArray[i * MAX_PROXIMITY_CHARS_SIZE + j]; + if (code == Constants.NOT_A_CODE) { + break; + } + if (sb.length() > 0) sb.append(" "); + sb.append(Constants.printableCode(code)); + } + Log.d(TAG, "proxmityChars["+i+"]: " + sb); + } + } + + final List<Key> sortedKeys = mSortedKeys; + final int keyCount = getProximityInfoKeysCount(sortedKeys); + final int[] keyXCoordinates = new int[keyCount]; + final int[] keyYCoordinates = new int[keyCount]; + final int[] keyWidths = new int[keyCount]; + final int[] keyHeights = new int[keyCount]; + final int[] keyCharCodes = new int[keyCount]; + final float[] sweetSpotCenterXs; + final float[] sweetSpotCenterYs; + final float[] sweetSpotRadii; + + for (int infoIndex = 0, keyIndex = 0; keyIndex < sortedKeys.size(); keyIndex++) { + final Key key = sortedKeys.get(keyIndex); + // Excluding from key coordinate arrays + if (!needsProximityInfo(key)) { + continue; + } + keyXCoordinates[infoIndex] = key.getX(); + keyYCoordinates[infoIndex] = key.getY(); + keyWidths[infoIndex] = key.getWidth(); + keyHeights[infoIndex] = key.getHeight(); + keyCharCodes[infoIndex] = key.getCode(); + infoIndex++; + } + + if (touchPositionCorrection.isValid()) { + if (DEBUG) { + Log.d(TAG, "touchPositionCorrection: ON"); + } + sweetSpotCenterXs = new float[keyCount]; + sweetSpotCenterYs = new float[keyCount]; + sweetSpotRadii = new float[keyCount]; + final int rows = touchPositionCorrection.getRows(); + final float defaultRadius = DEFAULT_TOUCH_POSITION_CORRECTION_RADIUS + * (float)Math.hypot(mMostCommonKeyWidth, mMostCommonKeyHeight); + for (int infoIndex = 0, keyIndex = 0; keyIndex < sortedKeys.size(); keyIndex++) { + final Key key = sortedKeys.get(keyIndex); + // Excluding from touch position correction arrays + if (!needsProximityInfo(key)) { + continue; + } + final Rect hitBox = key.getHitBox(); + sweetSpotCenterXs[infoIndex] = hitBox.exactCenterX(); + sweetSpotCenterYs[infoIndex] = hitBox.exactCenterY(); + sweetSpotRadii[infoIndex] = defaultRadius; + final int row = hitBox.top / mMostCommonKeyHeight; + if (row < rows) { + final int hitBoxWidth = hitBox.width(); + final int hitBoxHeight = hitBox.height(); + final float hitBoxDiagonal = (float)Math.hypot(hitBoxWidth, hitBoxHeight); + sweetSpotCenterXs[infoIndex] += + touchPositionCorrection.getX(row) * hitBoxWidth; + sweetSpotCenterYs[infoIndex] += + touchPositionCorrection.getY(row) * hitBoxHeight; + sweetSpotRadii[infoIndex] = + touchPositionCorrection.getRadius(row) * hitBoxDiagonal; + } + if (DEBUG) { + Log.d(TAG, String.format( + " [%2d] row=%d x/y/r=%7.2f/%7.2f/%5.2f %s code=%s", infoIndex, row, + sweetSpotCenterXs[infoIndex], sweetSpotCenterYs[infoIndex], + sweetSpotRadii[infoIndex], (row < rows ? "correct" : "default"), + Constants.printableCode(key.getCode()))); + } + infoIndex++; + } + } else { + sweetSpotCenterXs = sweetSpotCenterYs = sweetSpotRadii = null; + if (DEBUG) { + Log.d(TAG, "touchPositionCorrection: OFF"); + } + } + + // TODO: Stop passing proximityCharsArray + return setProximityInfoNative(mKeyboardMinWidth, mKeyboardHeight, mGridWidth, mGridHeight, + mMostCommonKeyWidth, mMostCommonKeyHeight, proximityCharsArray, keyCount, + keyXCoordinates, keyYCoordinates, keyWidths, keyHeights, keyCharCodes, + sweetSpotCenterXs, sweetSpotCenterYs, sweetSpotRadii); + } + + public long getNativeProximityInfo() { + return mNativeProximityInfo; + } + + @Override + protected void finalize() throws Throwable { + try { + if (mNativeProximityInfo != 0) { + releaseProximityInfoNative(mNativeProximityInfo); + mNativeProximityInfo = 0; + } + } finally { + super.finalize(); + } + } + + private void computeNearestNeighbors() { + final int defaultWidth = mMostCommonKeyWidth; + final int keyCount = mSortedKeys.size(); + final int gridSize = mGridNeighbors.length; + final int threshold = (int) (defaultWidth * SEARCH_DISTANCE); + final int thresholdSquared = threshold * threshold; + // Round-up so we don't have any pixels outside the grid + final int lastPixelXCoordinate = mGridWidth * mCellWidth - 1; + final int lastPixelYCoordinate = mGridHeight * mCellHeight - 1; + + // For large layouts, 'neighborsFlatBuffer' is about 80k of memory: gridSize is usually 512, + // keycount is about 40 and a pointer to a Key is 4 bytes. This contains, for each cell, + // enough space for as many keys as there are on the keyboard. Hence, every + // keycount'th element is the start of a new cell, and each of these virtual subarrays + // start empty with keycount spaces available. This fills up gradually in the loop below. + // Since in the practice each cell does not have a lot of neighbors, most of this space is + // actually just empty padding in this fixed-size buffer. + final Key[] neighborsFlatBuffer = new Key[gridSize * keyCount]; + final int[] neighborCountPerCell = new int[gridSize]; + final int halfCellWidth = mCellWidth / 2; + final int halfCellHeight = mCellHeight / 2; + for (final Key key : mSortedKeys) { + if (key.isSpacer()) continue; + +/* HOW WE PRE-SELECT THE CELLS (iterate over only the relevant cells, instead of all of them) + + We want to compute the distance for keys that are in the cells that are close enough to the + key border, as this method is performance-critical. These keys are represented with 'star' + background on the diagram below. Let's consider the Y case first. + + We want to select the cells which center falls between the top of the key minus the threshold, + and the bottom of the key plus the threshold. + topPixelWithinThreshold is key.mY - threshold, and bottomPixelWithinThreshold is + key.mY + key.mHeight + threshold. + + Then we need to compute the center of the top row that we need to evaluate, as we'll iterate + from there. + +(0,0)----> x +| .-------------------------------------------. +| | | | | | | | | | | | | +| |---+---+---+---+---+---+---+---+---+---+---| .- top of top cell (aligned on the grid) +| | | | | | | | | | | | | | +| |-----------+---+---+---+---+---+---+---+---|---' v +| | | | |***|***|*_________________________ topPixelWithinThreshold | yDeltaToGrid +| |---+---+---+-----^-+-|-+---+---+---+---+---| ^ +| | | | |***|*|*|*|*|***|***| | | | ______________________________________ +v |---+---+--threshold--|-+---+---+---+---+---| | + | | | |***|*|*|*|*|***|***| | | | | Starting from key.mY, we substract +y |---+---+---+---+-v-+-|-+---+---+---+---+---| | thresholdBase and get the top pixel + | | | |***|**########------------------- key.mY | within the threshold. We align that on + |---+---+---+---+--#+---+-#-+---+---+---+---| | the grid by computing the delta to the + | | | |***|**#|***|*#*|***| | | | | grid, and get the top of the top cell. + |---+---+---+---+--#+---+-#-+---+---+---+---| | + | | | |***|**########*|***| | | | | Adding half the cell height to the top + |---+---+---+---+---+-|-+---+---+---+---+---| | of the top cell, we get the middle of + | | | |***|***|*|*|***|***| | | | | the top cell (yMiddleOfTopCell). + |---+---+---+---+---+-|-+---+---+---+---+---| | + | | | |***|***|*|*|***|***| | | | | + |---+---+---+---+---+-|________________________ yEnd | Since we only want to add the key to + | | | | | | | (bottomPixelWithinThreshold) | the proximity if it's close enough to + |---+---+---+---+---+---+---+---+---+---+---| | the center of the cell, we only need + | | | | | | | | | | | | | to compute for these cells where + '---'---'---'---'---'---'---'---'---'---'---' | topPixelWithinThreshold is above the + (positive x,y) | center of the cell. This is the case + | when yDeltaToGrid is less than half + [Zoomed in diagram] | the height of the cell. + +-------+-------+-------+-------+-------+ | + | | | | | | | On the zoomed in diagram, on the right + | | | | | | | the topPixelWithinThreshold (represented + | | | | | | top of | with an = sign) is below and we can skip + +-------+-------+-------+--v----+-------+ .. top cell | this cell, while on the left it's above + | | = topPixelWT | | yDeltaToGrid | and we need to compute for this cell. + |..yStart.|.....|.......|..|....|.......|... y middle | Thus, if yDeltaToGrid is more than half + | (left)| | | ^ = | | of top cell | the height of the cell, we start the + +-------+-|-----+-------+----|--+-------+ | iteration one cell below the top cell, + | | | | | | | | | else we start it on the top cell. This + |.......|.|.....|.......|....|..|.....yStart (right) | is stored in yStart. + + Since we only want to go up to bottomPixelWithinThreshold, and we only iterate on the center + of the keys, we can stop as soon as the y value exceeds bottomPixelThreshold, so we don't + have to align this on the center of the key. Hence, we don't need a separate value for + bottomPixelWithinThreshold and call this yEnd right away. +*/ + final int keyX = key.getX(); + final int keyY = key.getY(); + final int topPixelWithinThreshold = keyY - threshold; + final int yDeltaToGrid = topPixelWithinThreshold % mCellHeight; + final int yMiddleOfTopCell = topPixelWithinThreshold - yDeltaToGrid + halfCellHeight; + final int yStart = Math.max(halfCellHeight, + yMiddleOfTopCell + (yDeltaToGrid <= halfCellHeight ? 0 : mCellHeight)); + final int yEnd = Math.min(lastPixelYCoordinate, keyY + key.getHeight() + threshold); + + final int leftPixelWithinThreshold = keyX - threshold; + final int xDeltaToGrid = leftPixelWithinThreshold % mCellWidth; + final int xMiddleOfLeftCell = leftPixelWithinThreshold - xDeltaToGrid + halfCellWidth; + final int xStart = Math.max(halfCellWidth, + xMiddleOfLeftCell + (xDeltaToGrid <= halfCellWidth ? 0 : mCellWidth)); + final int xEnd = Math.min(lastPixelXCoordinate, keyX + key.getWidth() + threshold); + + int baseIndexOfCurrentRow = (yStart / mCellHeight) * mGridWidth + (xStart / mCellWidth); + for (int centerY = yStart; centerY <= yEnd; centerY += mCellHeight) { + int index = baseIndexOfCurrentRow; + for (int centerX = xStart; centerX <= xEnd; centerX += mCellWidth) { + if (key.squaredDistanceToEdge(centerX, centerY) < thresholdSquared) { + neighborsFlatBuffer[index * keyCount + neighborCountPerCell[index]] = key; + ++neighborCountPerCell[index]; + } + ++index; + } + baseIndexOfCurrentRow += mGridWidth; + } + } + + for (int i = 0; i < gridSize; ++i) { + final int indexStart = i * keyCount; + final int indexEnd = indexStart + neighborCountPerCell[i]; + final ArrayList<Key> neighbors = new ArrayList<>(indexEnd - indexStart); + for (int index = indexStart; index < indexEnd; index++) { + neighbors.add(neighborsFlatBuffer[index]); + } + mGridNeighbors[i] = Collections.unmodifiableList(neighbors); + } + } + + public void fillArrayWithNearestKeyCodes(final int x, final int y, final int primaryKeyCode, + final int[] dest) { + final int destLength = dest.length; + if (destLength < 1) { + return; + } + int index = 0; + if (primaryKeyCode > Constants.CODE_SPACE) { + dest[index++] = primaryKeyCode; + } + final List<Key> nearestKeys = getNearestKeys(x, y); + for (Key key : nearestKeys) { + if (index >= destLength) { + break; + } + final int code = key.getCode(); + if (code <= Constants.CODE_SPACE) { + break; + } + dest[index++] = code; + } + if (index < destLength) { + dest[index] = Constants.NOT_A_CODE; + } + } + + @Nonnull + public List<Key> getNearestKeys(final int x, final int y) { + if (x >= 0 && x < mKeyboardMinWidth && y >= 0 && y < mKeyboardHeight) { + int index = (y / mCellHeight) * mGridWidth + (x / mCellWidth); + if (index < mGridSize) { + return mGridNeighbors[index]; + } + } + return EMPTY_KEY_LIST; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/DynamicGridKeyboard.java b/java/src/org/kelar/inputmethod/keyboard/emoji/DynamicGridKeyboard.java new file mode 100644 index 000000000..0c0999fbd --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/DynamicGridKeyboard.java @@ -0,0 +1,264 @@ +/* + * 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 org.kelar.inputmethod.keyboard.emoji; + +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.utils.JsonUtils; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * This is a Keyboard class where you can add keys dynamically shown in a grid layout + */ +final class DynamicGridKeyboard extends Keyboard { + private static final String TAG = DynamicGridKeyboard.class.getSimpleName(); + private static final int TEMPLATE_KEY_CODE_0 = 0x30; + private static final int TEMPLATE_KEY_CODE_1 = 0x31; + private final Object mLock = new Object(); + + private final SharedPreferences mPrefs; + private final int mHorizontalStep; + private final int mVerticalStep; + private final int mColumnsNum; + private final int mMaxKeyCount; + private final boolean mIsRecents; + private final ArrayDeque<GridKey> mGridKeys = new ArrayDeque<>(); + private final ArrayDeque<Key> mPendingKeys = new ArrayDeque<>(); + + private List<Key> mCachedGridKeys; + + public DynamicGridKeyboard(final SharedPreferences prefs, final Keyboard templateKeyboard, + final int maxKeyCount, final int categoryId) { + super(templateKeyboard); + final Key key0 = getTemplateKey(TEMPLATE_KEY_CODE_0); + final Key key1 = getTemplateKey(TEMPLATE_KEY_CODE_1); + mHorizontalStep = Math.abs(key1.getX() - key0.getX()); + mVerticalStep = key0.getHeight() + mVerticalGap; + mColumnsNum = mBaseWidth / mHorizontalStep; + mMaxKeyCount = maxKeyCount; + mIsRecents = categoryId == EmojiCategory.ID_RECENTS; + mPrefs = prefs; + } + + private Key getTemplateKey(final int code) { + for (final Key key : super.getSortedKeys()) { + if (key.getCode() == code) { + return key; + } + } + throw new RuntimeException("Can't find template key: code=" + code); + } + + public void addPendingKey(final Key usedKey) { + synchronized (mLock) { + mPendingKeys.addLast(usedKey); + } + } + + public void flushPendingRecentKeys() { + synchronized (mLock) { + while (!mPendingKeys.isEmpty()) { + addKey(mPendingKeys.pollFirst(), true); + } + saveRecentKeys(); + } + } + + public void addKeyFirst(final Key usedKey) { + addKey(usedKey, true); + if (mIsRecents) { + saveRecentKeys(); + } + } + + public void addKeyLast(final Key usedKey) { + addKey(usedKey, false); + } + + private void addKey(final Key usedKey, final boolean addFirst) { + if (usedKey == null) { + return; + } + synchronized (mLock) { + mCachedGridKeys = null; + final GridKey key = new GridKey(usedKey); + while (mGridKeys.remove(key)) { + // Remove duplicate keys. + } + if (addFirst) { + mGridKeys.addFirst(key); + } else { + mGridKeys.addLast(key); + } + while (mGridKeys.size() > mMaxKeyCount) { + mGridKeys.removeLast(); + } + int index = 0; + for (final GridKey gridKey : mGridKeys) { + final int keyX0 = getKeyX0(index); + final int keyY0 = getKeyY0(index); + final int keyX1 = getKeyX1(index); + final int keyY1 = getKeyY1(index); + gridKey.updateCoordinates(keyX0, keyY0, keyX1, keyY1); + index++; + } + } + } + + private void saveRecentKeys() { + final ArrayList<Object> keys = new ArrayList<>(); + for (final Key key : mGridKeys) { + if (key.getOutputText() != null) { + keys.add(key.getOutputText()); + } else { + keys.add(key.getCode()); + } + } + final String jsonStr = JsonUtils.listToJsonStr(keys); + Settings.writeEmojiRecentKeys(mPrefs, jsonStr); + } + + private static Key getKeyByCode(final Collection<DynamicGridKeyboard> keyboards, + final int code) { + for (final DynamicGridKeyboard keyboard : keyboards) { + for (final Key key : keyboard.getSortedKeys()) { + if (key.getCode() == code) { + return key; + } + } + } + return null; + } + + private static Key getKeyByOutputText(final Collection<DynamicGridKeyboard> keyboards, + final String outputText) { + for (final DynamicGridKeyboard keyboard : keyboards) { + for (final Key key : keyboard.getSortedKeys()) { + if (outputText.equals(key.getOutputText())) { + return key; + } + } + } + return null; + } + + public void loadRecentKeys(final Collection<DynamicGridKeyboard> keyboards) { + final String str = Settings.readEmojiRecentKeys(mPrefs); + final List<Object> keys = JsonUtils.jsonStrToList(str); + for (final Object o : keys) { + final Key key; + if (o instanceof Integer) { + final int code = (Integer)o; + key = getKeyByCode(keyboards, code); + } else if (o instanceof String) { + final String outputText = (String)o; + key = getKeyByOutputText(keyboards, outputText); + } else { + Log.w(TAG, "Invalid object: " + o); + continue; + } + addKeyLast(key); + } + } + + private int getKeyX0(final int index) { + final int column = index % mColumnsNum; + return column * mHorizontalStep; + } + + private int getKeyX1(final int index) { + final int column = index % mColumnsNum + 1; + return column * mHorizontalStep; + } + + private int getKeyY0(final int index) { + final int row = index / mColumnsNum; + return row * mVerticalStep + mVerticalGap / 2; + } + + private int getKeyY1(final int index) { + final int row = index / mColumnsNum + 1; + return row * mVerticalStep + mVerticalGap / 2; + } + + @Override + public List<Key> getSortedKeys() { + synchronized (mLock) { + if (mCachedGridKeys != null) { + return mCachedGridKeys; + } + final ArrayList<Key> cachedKeys = new ArrayList<Key>(mGridKeys); + mCachedGridKeys = Collections.unmodifiableList(cachedKeys); + return mCachedGridKeys; + } + } + + @Override + public List<Key> getNearestKeys(final int x, final int y) { + // TODO: Calculate the nearest key index in mGridKeys from x and y. + return getSortedKeys(); + } + + static final class GridKey extends Key { + private int mCurrentX; + private int mCurrentY; + + public GridKey(final Key originalKey) { + super(originalKey); + } + + public void updateCoordinates(final int x0, final int y0, final int x1, final int y1) { + mCurrentX = x0; + mCurrentY = y0; + getHitBox().set(x0, y0, x1, y1); + } + + @Override + public int getX() { + return mCurrentX; + } + + @Override + public int getY() { + return mCurrentY; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Key)) return false; + final Key key = (Key)o; + if (getCode() != key.getCode()) return false; + if (!TextUtils.equals(getLabel(), key.getLabel())) return false; + return TextUtils.equals(getOutputText(), key.getOutputText()); + } + + @Override + public String toString() { + return "GridKey: " + super.toString(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategory.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategory.java new file mode 100644 index 000000000..9d86059d5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategory.java @@ -0,0 +1,470 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.keyboard.emoji; + +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Paint; +import android.graphics.Rect; +import android.os.Build; +import android.util.Log; +import android.util.Pair; + +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.keyboard.KeyboardLayoutSet; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.settings.Settings; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +final class EmojiCategory { + private final String TAG = EmojiCategory.class.getSimpleName(); + + private static final int ID_UNSPECIFIED = -1; + public static final int ID_RECENTS = 0; + private static final int ID_PEOPLE = 1; + private static final int ID_OBJECTS = 2; + private static final int ID_NATURE = 3; + private static final int ID_PLACES = 4; + private static final int ID_SYMBOLS = 5; + private static final int ID_EMOTICONS = 6; + private static final int ID_FLAGS = 7; + private static final int ID_EIGHT_SMILEY_PEOPLE = 8; + private static final int ID_EIGHT_ANIMALS_NATURE = 9; + private static final int ID_EIGHT_FOOD_DRINK = 10; + private static final int ID_EIGHT_TRAVEL_PLACES = 11; + private static final int ID_EIGHT_ACTIVITY = 12; + private static final int ID_EIGHT_OBJECTS = 13; + private static final int ID_EIGHT_SYMBOLS = 14; + private static final int ID_EIGHT_FLAGS = 15; + private static final int ID_EIGHT_SMILEY_PEOPLE_BORING = 16; + + public final class CategoryProperties { + public final int mCategoryId; + public final int mPageCount; + public CategoryProperties(final int categoryId, final int pageCount) { + mCategoryId = categoryId; + mPageCount = pageCount; + } + } + + private static final String[] sCategoryName = { + "recents", + "people", + "objects", + "nature", + "places", + "symbols", + "emoticons", + "flags", + "smiley & people", + "animals & nature", + "food & drink", + "travel & places", + "activity", + "objects2", + "symbols2", + "flags2", + "smiley & people2" }; + + private static final int[] sCategoryTabIconAttr = { + R.styleable.EmojiPalettesView_iconEmojiRecentsTab, + R.styleable.EmojiPalettesView_iconEmojiCategory1Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory2Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory3Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory4Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory5Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory6Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory7Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory8Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory9Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory10Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory11Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory12Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory13Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory14Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory15Tab, + R.styleable.EmojiPalettesView_iconEmojiCategory16Tab }; + + private static final int[] sAccessibilityDescriptionResourceIdsForCategories = { + R.string.spoken_descrption_emoji_category_recents, + R.string.spoken_descrption_emoji_category_people, + R.string.spoken_descrption_emoji_category_objects, + R.string.spoken_descrption_emoji_category_nature, + R.string.spoken_descrption_emoji_category_places, + R.string.spoken_descrption_emoji_category_symbols, + R.string.spoken_descrption_emoji_category_emoticons, + R.string.spoken_descrption_emoji_category_flags, + R.string.spoken_descrption_emoji_category_eight_smiley_people, + R.string.spoken_descrption_emoji_category_eight_animals_nature, + R.string.spoken_descrption_emoji_category_eight_food_drink, + R.string.spoken_descrption_emoji_category_eight_travel_places, + R.string.spoken_descrption_emoji_category_eight_activity, + R.string.spoken_descrption_emoji_category_objects, + R.string.spoken_descrption_emoji_category_symbols, + R.string.spoken_descrption_emoji_category_flags, + R.string.spoken_descrption_emoji_category_eight_smiley_people }; + + private static final int[] sCategoryElementId = { + KeyboardId.ELEMENT_EMOJI_RECENTS, + KeyboardId.ELEMENT_EMOJI_CATEGORY1, + KeyboardId.ELEMENT_EMOJI_CATEGORY2, + KeyboardId.ELEMENT_EMOJI_CATEGORY3, + KeyboardId.ELEMENT_EMOJI_CATEGORY4, + KeyboardId.ELEMENT_EMOJI_CATEGORY5, + KeyboardId.ELEMENT_EMOJI_CATEGORY6, + KeyboardId.ELEMENT_EMOJI_CATEGORY7, + KeyboardId.ELEMENT_EMOJI_CATEGORY8, + KeyboardId.ELEMENT_EMOJI_CATEGORY9, + KeyboardId.ELEMENT_EMOJI_CATEGORY10, + KeyboardId.ELEMENT_EMOJI_CATEGORY11, + KeyboardId.ELEMENT_EMOJI_CATEGORY12, + KeyboardId.ELEMENT_EMOJI_CATEGORY13, + KeyboardId.ELEMENT_EMOJI_CATEGORY14, + KeyboardId.ELEMENT_EMOJI_CATEGORY15, + KeyboardId.ELEMENT_EMOJI_CATEGORY16 }; + + private final SharedPreferences mPrefs; + private final Resources mRes; + private final int mMaxPageKeyCount; + private final KeyboardLayoutSet mLayoutSet; + private final HashMap<String, Integer> mCategoryNameToIdMap = new HashMap<>(); + private final int[] mCategoryTabIconId = new int[sCategoryName.length]; + private final ArrayList<CategoryProperties> mShownCategories = new ArrayList<>(); + private final ConcurrentHashMap<Long, DynamicGridKeyboard> mCategoryKeyboardMap = + new ConcurrentHashMap<>(); + + private int mCurrentCategoryId = EmojiCategory.ID_UNSPECIFIED; + private int mCurrentCategoryPageId = 0; + + public EmojiCategory(final SharedPreferences prefs, final Resources res, + final KeyboardLayoutSet layoutSet, final TypedArray emojiPaletteViewAttr) { + mPrefs = prefs; + mRes = res; + mMaxPageKeyCount = res.getInteger(R.integer.config_emoji_keyboard_max_page_key_count); + mLayoutSet = layoutSet; + for (int i = 0; i < sCategoryName.length; ++i) { + mCategoryNameToIdMap.put(sCategoryName[i], i); + mCategoryTabIconId[i] = emojiPaletteViewAttr.getResourceId( + sCategoryTabIconAttr[i], 0); + } + + int defaultCategoryId = EmojiCategory.ID_SYMBOLS; + addShownCategoryId(EmojiCategory.ID_RECENTS); + if (BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.KITKAT) { + if (canShowUnicodeEightEmoji()) { + defaultCategoryId = EmojiCategory.ID_EIGHT_SMILEY_PEOPLE; + addShownCategoryId(EmojiCategory.ID_EIGHT_SMILEY_PEOPLE); + addShownCategoryId(EmojiCategory.ID_EIGHT_ANIMALS_NATURE); + addShownCategoryId(EmojiCategory.ID_EIGHT_FOOD_DRINK); + addShownCategoryId(EmojiCategory.ID_EIGHT_TRAVEL_PLACES); + addShownCategoryId(EmojiCategory.ID_EIGHT_ACTIVITY); + addShownCategoryId(EmojiCategory.ID_EIGHT_OBJECTS); + addShownCategoryId(EmojiCategory.ID_EIGHT_SYMBOLS); + addShownCategoryId(EmojiCategory.ID_FLAGS); // Exclude combinations without glyphs. + } else { + defaultCategoryId = EmojiCategory.ID_PEOPLE; + addShownCategoryId(EmojiCategory.ID_PEOPLE); + addShownCategoryId(EmojiCategory.ID_OBJECTS); + addShownCategoryId(EmojiCategory.ID_NATURE); + addShownCategoryId(EmojiCategory.ID_PLACES); + addShownCategoryId(EmojiCategory.ID_SYMBOLS); + if (canShowFlagEmoji()) { + addShownCategoryId(EmojiCategory.ID_FLAGS); + } + } + } else { + addShownCategoryId(EmojiCategory.ID_SYMBOLS); + } + addShownCategoryId(EmojiCategory.ID_EMOTICONS); + + DynamicGridKeyboard recentsKbd = + getKeyboard(EmojiCategory.ID_RECENTS, 0 /* categoryPageId */); + recentsKbd.loadRecentKeys(mCategoryKeyboardMap.values()); + + mCurrentCategoryId = Settings.readLastShownEmojiCategoryId(mPrefs, defaultCategoryId); + Log.i(TAG, "Last Emoji category id is " + mCurrentCategoryId); + if (!isShownCategoryId(mCurrentCategoryId)) { + Log.i(TAG, "Last emoji category " + mCurrentCategoryId + + " is invalid, starting in " + defaultCategoryId); + mCurrentCategoryId = defaultCategoryId; + } else if (mCurrentCategoryId == EmojiCategory.ID_RECENTS && + recentsKbd.getSortedKeys().isEmpty()) { + Log.i(TAG, "No recent emojis found, starting in category " + defaultCategoryId); + mCurrentCategoryId = defaultCategoryId; + } + } + + private void addShownCategoryId(final int categoryId) { + // Load a keyboard of categoryId + getKeyboard(categoryId, 0 /* categoryPageId */); + final CategoryProperties properties = + new CategoryProperties(categoryId, getCategoryPageCount(categoryId)); + mShownCategories.add(properties); + } + + private boolean isShownCategoryId(final int categoryId) { + for (final CategoryProperties prop : mShownCategories) { + if (prop.mCategoryId == categoryId) { + return true; + } + } + return false; + } + + public static String getCategoryName(final int categoryId, final int categoryPageId) { + return sCategoryName[categoryId] + "-" + categoryPageId; + } + + public int getCategoryId(final String name) { + final String[] strings = name.split("-"); + return mCategoryNameToIdMap.get(strings[0]); + } + + public int getCategoryTabIcon(final int categoryId) { + return mCategoryTabIconId[categoryId]; + } + + public String getAccessibilityDescription(final int categoryId) { + return mRes.getString(sAccessibilityDescriptionResourceIdsForCategories[categoryId]); + } + + public ArrayList<CategoryProperties> getShownCategories() { + return mShownCategories; + } + + public int getCurrentCategoryId() { + return mCurrentCategoryId; + } + + public int getCurrentCategoryPageSize() { + return getCategoryPageSize(mCurrentCategoryId); + } + + public int getCategoryPageSize(final int categoryId) { + for (final CategoryProperties prop : mShownCategories) { + if (prop.mCategoryId == categoryId) { + return prop.mPageCount; + } + } + Log.w(TAG, "Invalid category id: " + categoryId); + // Should not reach here. + return 0; + } + + public void setCurrentCategoryId(final int categoryId) { + mCurrentCategoryId = categoryId; + Settings.writeLastShownEmojiCategoryId(mPrefs, categoryId); + } + + public void setCurrentCategoryPageId(final int id) { + mCurrentCategoryPageId = id; + } + + public int getCurrentCategoryPageId() { + return mCurrentCategoryPageId; + } + + public void saveLastTypedCategoryPage() { + Settings.writeLastTypedEmojiCategoryPageId( + mPrefs, mCurrentCategoryId, mCurrentCategoryPageId); + } + + public boolean isInRecentTab() { + return mCurrentCategoryId == EmojiCategory.ID_RECENTS; + } + + public int getTabIdFromCategoryId(final int categoryId) { + for (int i = 0; i < mShownCategories.size(); ++i) { + if (mShownCategories.get(i).mCategoryId == categoryId) { + return i; + } + } + Log.w(TAG, "categoryId not found: " + categoryId); + return 0; + } + + // Returns the view pager's page position for the categoryId + public int getPageIdFromCategoryId(final int categoryId) { + final int lastSavedCategoryPageId = + Settings.readLastTypedEmojiCategoryPageId(mPrefs, categoryId); + int sum = 0; + for (int i = 0; i < mShownCategories.size(); ++i) { + final CategoryProperties props = mShownCategories.get(i); + if (props.mCategoryId == categoryId) { + return sum + lastSavedCategoryPageId; + } + sum += props.mPageCount; + } + Log.w(TAG, "categoryId not found: " + categoryId); + return 0; + } + + public int getRecentTabId() { + return getTabIdFromCategoryId(EmojiCategory.ID_RECENTS); + } + + private int getCategoryPageCount(final int categoryId) { + final Keyboard keyboard = mLayoutSet.getKeyboard(sCategoryElementId[categoryId]); + return (keyboard.getSortedKeys().size() - 1) / mMaxPageKeyCount + 1; + } + + // Returns a pair of the category id and the category page id from the view pager's page + // position. The category page id is numbered in each category. And the view page position + // is the position of the current shown page in the view pager which contains all pages of + // all categories. + public Pair<Integer, Integer> getCategoryIdAndPageIdFromPagePosition(final int position) { + int sum = 0; + for (final CategoryProperties properties : mShownCategories) { + final int temp = sum; + sum += properties.mPageCount; + if (sum > position) { + return new Pair<>(properties.mCategoryId, position - temp); + } + } + return null; + } + + // Returns a keyboard from the view pager's page position. + public DynamicGridKeyboard getKeyboardFromPagePosition(final int position) { + final Pair<Integer, Integer> categoryAndId = + getCategoryIdAndPageIdFromPagePosition(position); + if (categoryAndId != null) { + return getKeyboard(categoryAndId.first, categoryAndId.second); + } + return null; + } + + private static final Long getCategoryKeyboardMapKey(final int categoryId, final int id) { + return (((long) categoryId) << Integer.SIZE) | id; + } + + public DynamicGridKeyboard getKeyboard(final int categoryId, final int id) { + synchronized (mCategoryKeyboardMap) { + final Long categoryKeyboardMapKey = getCategoryKeyboardMapKey(categoryId, id); + if (mCategoryKeyboardMap.containsKey(categoryKeyboardMapKey)) { + return mCategoryKeyboardMap.get(categoryKeyboardMapKey); + } + + if (categoryId == EmojiCategory.ID_RECENTS) { + final DynamicGridKeyboard kbd = new DynamicGridKeyboard(mPrefs, + mLayoutSet.getKeyboard(KeyboardId.ELEMENT_EMOJI_RECENTS), + mMaxPageKeyCount, categoryId); + mCategoryKeyboardMap.put(categoryKeyboardMapKey, kbd); + return kbd; + } + + final Keyboard keyboard = mLayoutSet.getKeyboard(sCategoryElementId[categoryId]); + final Key[][] sortedKeys = sortKeysIntoPages( + keyboard.getSortedKeys(), mMaxPageKeyCount); + for (int pageId = 0; pageId < sortedKeys.length; ++pageId) { + final DynamicGridKeyboard tempKeyboard = new DynamicGridKeyboard(mPrefs, + mLayoutSet.getKeyboard(KeyboardId.ELEMENT_EMOJI_RECENTS), + mMaxPageKeyCount, categoryId); + for (final Key emojiKey : sortedKeys[pageId]) { + if (emojiKey == null) { + break; + } + tempKeyboard.addKeyLast(emojiKey); + } + mCategoryKeyboardMap.put( + getCategoryKeyboardMapKey(categoryId, pageId), tempKeyboard); + } + return mCategoryKeyboardMap.get(categoryKeyboardMapKey); + } + } + + public int getTotalPageCountOfAllCategories() { + int sum = 0; + for (CategoryProperties properties : mShownCategories) { + sum += properties.mPageCount; + } + return sum; + } + + private static Comparator<Key> EMOJI_KEY_COMPARATOR = new Comparator<Key>() { + @Override + public int compare(final Key lhs, final Key rhs) { + final Rect lHitBox = lhs.getHitBox(); + final Rect rHitBox = rhs.getHitBox(); + if (lHitBox.top < rHitBox.top) { + return -1; + } else if (lHitBox.top > rHitBox.top) { + return 1; + } + if (lHitBox.left < rHitBox.left) { + return -1; + } else if (lHitBox.left > rHitBox.left) { + return 1; + } + if (lhs.getCode() == rhs.getCode()) { + return 0; + } + return lhs.getCode() < rhs.getCode() ? -1 : 1; + } + }; + + private static Key[][] sortKeysIntoPages(final List<Key> inKeys, final int maxPageCount) { + final ArrayList<Key> keys = new ArrayList<>(inKeys); + Collections.sort(keys, EMOJI_KEY_COMPARATOR); + final int pageCount = (keys.size() - 1) / maxPageCount + 1; + final Key[][] retval = new Key[pageCount][maxPageCount]; + for (int i = 0; i < keys.size(); ++i) { + retval[i / maxPageCount][i % maxPageCount] = keys.get(i); + } + return retval; + } + + private static boolean canShowFlagEmoji() { + Paint paint = new Paint(); + String switzerland = "\uD83C\uDDE8\uD83C\uDDED"; // U+1F1E8 U+1F1ED Flag for Switzerland + try { + return paint.hasGlyph(switzerland); + } catch (NoSuchMethodError e) { + // Compare display width of single-codepoint emoji to width of flag emoji to determine + // whether flag is rendered as single glyph or two adjacent regional indicator symbols. + float flagWidth = paint.measureText(switzerland); + float standardWidth = paint.measureText("\uD83D\uDC27"); // U+1F427 Penguin + return flagWidth < standardWidth * 1.25; + // This assumes that a valid glyph for the flag emoji must be less than 1.25 times + // the width of the penguin. + } + } + + private static boolean canShowUnicodeEightEmoji() { + Paint paint = new Paint(); + String cheese = "\uD83E\uDDC0"; // U+1F9C0 Cheese wedge + try { + return paint.hasGlyph(cheese); + } catch (NoSuchMethodError e) { + float cheeseWidth = paint.measureText(cheese); + float tofuWidth = paint.measureText("\uFFFE"); + return cheeseWidth > tofuWidth; + // This assumes that a valid glyph for the cheese wedge must be greater than the width + // of the noncharacter. + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategoryPageIndicatorView.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategoryPageIndicatorView.java new file mode 100644 index 000000000..c1bd0c7e8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategoryPageIndicatorView.java @@ -0,0 +1,70 @@ +/* + * 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 org.kelar.inputmethod.keyboard.emoji; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; + +public final class EmojiCategoryPageIndicatorView extends View { + private static final float BOTTOM_MARGIN_RATIO = 1.0f; + private final Paint mPaint = new Paint(); + private int mCategoryPageSize = 0; + private int mCurrentCategoryPageId = 0; + private float mOffset = 0.0f; + + public EmojiCategoryPageIndicatorView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public EmojiCategoryPageIndicatorView(final Context context, final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + } + + public void setColors(final int foregroundColor, final int backgroundColor) { + mPaint.setColor(foregroundColor); + setBackgroundColor(backgroundColor); + } + + public void setCategoryPageId(final int size, final int id, final float offset) { + mCategoryPageSize = size; + mCurrentCategoryPageId = id; + mOffset = offset; + invalidate(); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (mCategoryPageSize <= 1) { + // If the category is not set yet or contains only one category, + // just clear and return. + canvas.drawColor(0); + return; + } + final float height = getHeight(); + final float width = getWidth(); + final float unitWidth = width / mCategoryPageSize; + final float left = unitWidth * mCurrentCategoryPageId + mOffset * unitWidth; + final float top = 0.0f; + final float right = left + unitWidth; + final float bottom = height * BOTTOM_MARGIN_RATIO; + canvas.drawRect(left, top, right, bottom, mPaint); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiLayoutParams.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiLayoutParams.java new file mode 100644 index 000000000..26ac96999 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiLayoutParams.java @@ -0,0 +1,94 @@ +/* + * 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 org.kelar.inputmethod.keyboard.emoji; + +import android.content.Context; +import android.content.res.Resources; +import androidx.viewpager.widget.ViewPager; +import android.view.View; +import android.widget.LinearLayout; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +final class EmojiLayoutParams { + private static final int DEFAULT_KEYBOARD_ROWS = 4; + + public final int mEmojiPagerHeight; + private final int mEmojiPagerBottomMargin; + public final int mEmojiKeyboardHeight; + private final int mEmojiCategoryPageIdViewHeight; + public final int mEmojiActionBarHeight; + public final int mKeyVerticalGap; + private final int mKeyHorizontalGap; + private final int mBottomPadding; + private final int mTopPadding; + + public EmojiLayoutParams(final Context context) { + final Resources res = context.getResources(); + final int defaultKeyboardHeight = ResourceUtils.getDefaultKeyboardHeight(res); + final int defaultKeyboardWidth = ResourceUtils.getDefaultKeyboardWidth(context); + mKeyVerticalGap = (int) res.getFraction(R.fraction.config_key_vertical_gap_holo, + defaultKeyboardHeight, defaultKeyboardHeight); + mBottomPadding = (int) res.getFraction(R.fraction.config_keyboard_bottom_padding_holo, + defaultKeyboardHeight, defaultKeyboardHeight); + mTopPadding = (int) res.getFraction(R.fraction.config_keyboard_top_padding_holo, + defaultKeyboardHeight, defaultKeyboardHeight); + mKeyHorizontalGap = (int) (res.getFraction(R.fraction.config_key_horizontal_gap_holo, + defaultKeyboardWidth, defaultKeyboardWidth)); + mEmojiCategoryPageIdViewHeight = + (int) (res.getDimension(R.dimen.config_emoji_category_page_id_height)); + final int baseheight = defaultKeyboardHeight - mBottomPadding - mTopPadding + + mKeyVerticalGap; + mEmojiActionBarHeight = baseheight / DEFAULT_KEYBOARD_ROWS + - (mKeyVerticalGap - mBottomPadding) / 2; + mEmojiPagerHeight = defaultKeyboardHeight - mEmojiActionBarHeight + - mEmojiCategoryPageIdViewHeight; + mEmojiPagerBottomMargin = 0; + mEmojiKeyboardHeight = mEmojiPagerHeight - mEmojiPagerBottomMargin - 1; + } + + public void setPagerProperties(final ViewPager vp) { + final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) vp.getLayoutParams(); + lp.height = mEmojiKeyboardHeight; + lp.bottomMargin = mEmojiPagerBottomMargin; + vp.setLayoutParams(lp); + } + + public void setCategoryPageIdViewProperties(final View v) { + final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) v.getLayoutParams(); + lp.height = mEmojiCategoryPageIdViewHeight; + v.setLayoutParams(lp); + } + + public int getActionBarHeight() { + return mEmojiActionBarHeight - mBottomPadding; + } + + public void setActionBarProperties(final LinearLayout ll) { + final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) ll.getLayoutParams(); + lp.height = getActionBarHeight(); + ll.setLayoutParams(lp); + } + + public void setKeyProperties(final View v) { + final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) v.getLayoutParams(); + lp.leftMargin = mKeyHorizontalGap / 2; + lp.rightMargin = mKeyHorizontalGap / 2; + v.setLayoutParams(lp); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPageKeyboardView.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPageKeyboardView.java new file mode 100644 index 000000000..80d2dca9f --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPageKeyboardView.java @@ -0,0 +1,233 @@ +/* + * 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 org.kelar.inputmethod.keyboard.emoji; + +import android.content.Context; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.accessibility.AccessibilityEvent; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.accessibility.KeyboardAccessibilityDelegate; +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyDetector; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardView; +import org.kelar.inputmethod.latin.R; + +/** + * This is an extended {@link KeyboardView} class that hosts an emoji page keyboard. + * Multi-touch unsupported. No gesture support. + */ +// TODO: Implement key popup preview. +final class EmojiPageKeyboardView extends KeyboardView implements + GestureDetector.OnGestureListener { + private static final long KEY_PRESS_DELAY_TIME = 250; // msec + private static final long KEY_RELEASE_DELAY_TIME = 30; // msec + + public interface OnKeyEventListener { + public void onPressKey(Key key); + public void onReleaseKey(Key key); + } + + private static final OnKeyEventListener EMPTY_LISTENER = new OnKeyEventListener() { + @Override + public void onPressKey(final Key key) {} + @Override + public void onReleaseKey(final Key key) {} + }; + + private OnKeyEventListener mListener = EMPTY_LISTENER; + private final KeyDetector mKeyDetector = new KeyDetector(); + private final GestureDetector mGestureDetector; + private KeyboardAccessibilityDelegate<EmojiPageKeyboardView> mAccessibilityDelegate; + + public EmojiPageKeyboardView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.keyboardViewStyle); + } + + public EmojiPageKeyboardView(final Context context, final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + mGestureDetector = new GestureDetector(context, this); + mGestureDetector.setIsLongpressEnabled(false /* isLongpressEnabled */); + mHandler = new Handler(); + } + + public void setOnKeyEventListener(final OnKeyEventListener listener) { + mListener = listener; + } + + /** + * {@inheritDoc} + */ + @Override + public void setKeyboard(final Keyboard keyboard) { + super.setKeyboard(keyboard); + mKeyDetector.setKeyboard(keyboard, 0 /* correctionX */, 0 /* correctionY */); + if (AccessibilityUtils.getInstance().isAccessibilityEnabled()) { + if (mAccessibilityDelegate == null) { + mAccessibilityDelegate = new KeyboardAccessibilityDelegate<>(this, mKeyDetector); + } + mAccessibilityDelegate.setKeyboard(keyboard); + } else { + mAccessibilityDelegate = null; + } + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(final AccessibilityEvent event) { + // Don't populate accessibility event with all Emoji keys. + return true; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean onHoverEvent(final MotionEvent event) { + final KeyboardAccessibilityDelegate<EmojiPageKeyboardView> accessibilityDelegate = + mAccessibilityDelegate; + if (accessibilityDelegate != null + && AccessibilityUtils.getInstance().isTouchExplorationEnabled()) { + return accessibilityDelegate.onHoverEvent(event); + } + return super.onHoverEvent(event); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean onTouchEvent(final MotionEvent e) { + if (mGestureDetector.onTouchEvent(e)) { + return true; + } + final Key key = getKey(e); + if (key != null && key != mCurrentKey) { + releaseCurrentKey(false /* withKeyRegistering */); + } + return true; + } + + // {@link GestureEnabler#OnGestureListener} methods. + private Key mCurrentKey; + private Runnable mPendingKeyDown; + private final Handler mHandler; + + private Key getKey(final MotionEvent e) { + final int index = e.getActionIndex(); + final int x = (int)e.getX(index); + final int y = (int)e.getY(index); + return mKeyDetector.detectHitKey(x, y); + } + + void callListenerOnReleaseKey(final Key releasedKey, final boolean withKeyRegistering) { + releasedKey.onReleased(); + invalidateKey(releasedKey); + if (withKeyRegistering) { + mListener.onReleaseKey(releasedKey); + } + } + + void callListenerOnPressKey(final Key pressedKey) { + mPendingKeyDown = null; + pressedKey.onPressed(); + invalidateKey(pressedKey); + mListener.onPressKey(pressedKey); + } + + public void releaseCurrentKey(final boolean withKeyRegistering) { + mHandler.removeCallbacks(mPendingKeyDown); + mPendingKeyDown = null; + final Key currentKey = mCurrentKey; + if (currentKey == null) { + return; + } + callListenerOnReleaseKey(currentKey, withKeyRegistering); + mCurrentKey = null; + } + + @Override + public boolean onDown(final MotionEvent e) { + final Key key = getKey(e); + releaseCurrentKey(false /* withKeyRegistering */); + mCurrentKey = key; + if (key == null) { + return false; + } + // Do not trigger key-down effect right now in case this is actually a fling action. + mPendingKeyDown = new Runnable() { + @Override + public void run() { + callListenerOnPressKey(key); + } + }; + mHandler.postDelayed(mPendingKeyDown, KEY_PRESS_DELAY_TIME); + return false; + } + + @Override + public void onShowPress(final MotionEvent e) { + // User feedback is done at {@link #onDown(MotionEvent)}. + } + + @Override + public boolean onSingleTapUp(final MotionEvent e) { + final Key key = getKey(e); + final Runnable pendingKeyDown = mPendingKeyDown; + final Key currentKey = mCurrentKey; + releaseCurrentKey(false /* withKeyRegistering */); + if (key == null) { + return false; + } + if (key == currentKey && pendingKeyDown != null) { + pendingKeyDown.run(); + // Trigger key-release event a little later so that a user can see visual feedback. + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + callListenerOnReleaseKey(key, true /* withRegistering */); + } + }, KEY_RELEASE_DELAY_TIME); + } else { + callListenerOnReleaseKey(key, true /* withRegistering */); + } + return true; + } + + @Override + public boolean onScroll(final MotionEvent e1, final MotionEvent e2, final float distanceX, + final float distanceY) { + releaseCurrentKey(false /* withKeyRegistering */); + return false; + } + + @Override + public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, + final float velocityY) { + releaseCurrentKey(false /* withKeyRegistering */); + return false; + } + + @Override + public void onLongPress(final MotionEvent e) { + // Long press detection of {@link #mGestureDetector} is disabled and not used. + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesAdapter.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesAdapter.java new file mode 100644 index 000000000..8b638673f --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesAdapter.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.emoji; + +import androidx.viewpager.widget.PagerAdapter; +import android.util.Log; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardView; +import org.kelar.inputmethod.latin.R; + +final class EmojiPalettesAdapter extends PagerAdapter { + private static final String TAG = EmojiPalettesAdapter.class.getSimpleName(); + private static final boolean DEBUG_PAGER = false; + + private final EmojiPageKeyboardView.OnKeyEventListener mListener; + private final DynamicGridKeyboard mRecentsKeyboard; + private final SparseArray<EmojiPageKeyboardView> mActiveKeyboardViews = new SparseArray<>(); + private final EmojiCategory mEmojiCategory; + private int mActivePosition = 0; + + public EmojiPalettesAdapter(final EmojiCategory emojiCategory, + final EmojiPageKeyboardView.OnKeyEventListener listener) { + mEmojiCategory = emojiCategory; + mListener = listener; + mRecentsKeyboard = mEmojiCategory.getKeyboard(EmojiCategory.ID_RECENTS, 0); + } + + public void flushPendingRecentKeys() { + mRecentsKeyboard.flushPendingRecentKeys(); + final KeyboardView recentKeyboardView = + mActiveKeyboardViews.get(mEmojiCategory.getRecentTabId()); + if (recentKeyboardView != null) { + recentKeyboardView.invalidateAllKeys(); + } + } + + public void addRecentKey(final Key key) { + if (mEmojiCategory.isInRecentTab()) { + mRecentsKeyboard.addPendingKey(key); + return; + } + mRecentsKeyboard.addKeyFirst(key); + final KeyboardView recentKeyboardView = + mActiveKeyboardViews.get(mEmojiCategory.getRecentTabId()); + if (recentKeyboardView != null) { + recentKeyboardView.invalidateAllKeys(); + } + } + + public void onPageScrolled() { + releaseCurrentKey(false /* withKeyRegistering */); + } + + public void releaseCurrentKey(final boolean withKeyRegistering) { + // Make sure the delayed key-down event (highlight effect and haptic feedback) will be + // canceled. + final EmojiPageKeyboardView currentKeyboardView = + mActiveKeyboardViews.get(mActivePosition); + if (currentKeyboardView == null) { + return; + } + currentKeyboardView.releaseCurrentKey(withKeyRegistering); + } + + @Override + public int getCount() { + return mEmojiCategory.getTotalPageCountOfAllCategories(); + } + + @Override + public void setPrimaryItem(final ViewGroup container, final int position, + final Object object) { + if (mActivePosition == position) { + return; + } + final EmojiPageKeyboardView oldKeyboardView = mActiveKeyboardViews.get(mActivePosition); + if (oldKeyboardView != null) { + oldKeyboardView.releaseCurrentKey(false /* withKeyRegistering */); + oldKeyboardView.deallocateMemory(); + } + mActivePosition = position; + } + + @Override + public Object instantiateItem(final ViewGroup container, final int position) { + if (DEBUG_PAGER) { + Log.d(TAG, "instantiate item: " + position); + } + final EmojiPageKeyboardView oldKeyboardView = mActiveKeyboardViews.get(position); + if (oldKeyboardView != null) { + oldKeyboardView.deallocateMemory(); + // This may be redundant but wanted to be safer.. + mActiveKeyboardViews.remove(position); + } + final Keyboard keyboard = + mEmojiCategory.getKeyboardFromPagePosition(position); + final LayoutInflater inflater = LayoutInflater.from(container.getContext()); + final EmojiPageKeyboardView keyboardView = (EmojiPageKeyboardView)inflater.inflate( + R.layout.emoji_keyboard_page, container, false /* attachToRoot */); + keyboardView.setKeyboard(keyboard); + keyboardView.setOnKeyEventListener(mListener); + container.addView(keyboardView); + mActiveKeyboardViews.put(position, keyboardView); + return keyboardView; + } + + @Override + public boolean isViewFromObject(final View view, final Object object) { + return view == object; + } + + @Override + public void destroyItem(final ViewGroup container, final int position, + final Object object) { + if (DEBUG_PAGER) { + Log.d(TAG, "destroy item: " + position + ", " + object.getClass().getSimpleName()); + } + final EmojiPageKeyboardView keyboardView = mActiveKeyboardViews.get(position); + if (keyboardView != null) { + keyboardView.deallocateMemory(); + mActiveKeyboardViews.remove(position); + } + if (object instanceof View) { + container.removeView((View)object); + } else { + Log.w(TAG, "Warning!!! Emoji palette may be leaking. " + object); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesView.java b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesView.java new file mode 100644 index 000000000..08c6b4c18 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesView.java @@ -0,0 +1,486 @@ +/* + * 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 org.kelar.inputmethod.keyboard.emoji; + +import static org.kelar.inputmethod.latin.common.Constants.NOT_A_COORDINATE; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.preference.PreferenceManager; +import androidx.viewpager.widget.ViewPager; +import android.util.AttributeSet; +import android.util.Pair; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TabHost; +import android.widget.TabHost.OnTabChangeListener; +import android.widget.TabWidget; +import android.widget.TextView; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyboardActionListener; +import org.kelar.inputmethod.keyboard.KeyboardLayoutSet; +import org.kelar.inputmethod.keyboard.KeyboardView; +import org.kelar.inputmethod.keyboard.internal.KeyDrawParams; +import org.kelar.inputmethod.keyboard.internal.KeyVisualAttributes; +import org.kelar.inputmethod.keyboard.internal.KeyboardIconsSet; +import org.kelar.inputmethod.latin.AudioAndHapticFeedbackManager; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +/** + * View class to implement Emoji palettes. + * The Emoji keyboard consists of group of views layout/emoji_palettes_view. + * <ol> + * <li> Emoji category tabs. + * <li> Delete button. + * <li> Emoji keyboard pages that can be scrolled by swiping horizontally or by selecting a tab. + * <li> Back to main keyboard button and enter button. + * </ol> + * Because of the above reasons, this class doesn't extend {@link KeyboardView}. + */ +public final class EmojiPalettesView extends LinearLayout implements OnTabChangeListener, + ViewPager.OnPageChangeListener, View.OnClickListener, View.OnTouchListener, + EmojiPageKeyboardView.OnKeyEventListener { + private final int mFunctionalKeyBackgroundId; + private final int mSpacebarBackgroundId; + private final boolean mCategoryIndicatorEnabled; + private final int mCategoryIndicatorDrawableResId; + private final int mCategoryIndicatorBackgroundResId; + private final int mCategoryPageIndicatorColor; + private final int mCategoryPageIndicatorBackground; + private EmojiPalettesAdapter mEmojiPalettesAdapter; + private final EmojiLayoutParams mEmojiLayoutParams; + private final DeleteKeyOnTouchListener mDeleteKeyOnTouchListener; + + private ImageButton mDeleteKey; + private TextView mAlphabetKeyLeft; + private TextView mAlphabetKeyRight; + private View mSpacebar; + // TODO: Remove this workaround. + private View mSpacebarIcon; + private TabHost mTabHost; + private ViewPager mEmojiPager; + private int mCurrentPagerPosition = 0; + private EmojiCategoryPageIndicatorView mEmojiCategoryPageIndicatorView; + + private KeyboardActionListener mKeyboardActionListener = KeyboardActionListener.EMPTY_LISTENER; + + private final EmojiCategory mEmojiCategory; + + public EmojiPalettesView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.emojiPalettesViewStyle); + } + + public EmojiPalettesView(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + final TypedArray keyboardViewAttr = context.obtainStyledAttributes(attrs, + R.styleable.KeyboardView, defStyle, R.style.KeyboardView); + final int keyBackgroundId = keyboardViewAttr.getResourceId( + R.styleable.KeyboardView_keyBackground, 0); + mFunctionalKeyBackgroundId = keyboardViewAttr.getResourceId( + R.styleable.KeyboardView_functionalKeyBackground, keyBackgroundId); + mSpacebarBackgroundId = keyboardViewAttr.getResourceId( + R.styleable.KeyboardView_spacebarBackground, keyBackgroundId); + keyboardViewAttr.recycle(); + final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder( + context, null /* editorInfo */); + final Resources res = context.getResources(); + mEmojiLayoutParams = new EmojiLayoutParams(context); + builder.setSubtype(RichInputMethodSubtype.getEmojiSubtype()); + builder.setKeyboardGeometry(ResourceUtils.getDefaultKeyboardWidth(context), + mEmojiLayoutParams.mEmojiKeyboardHeight); + final KeyboardLayoutSet layoutSet = builder.build(); + final TypedArray emojiPalettesViewAttr = context.obtainStyledAttributes(attrs, + R.styleable.EmojiPalettesView, defStyle, R.style.EmojiPalettesView); + mEmojiCategory = new EmojiCategory(PreferenceManager.getDefaultSharedPreferences(context), + res, layoutSet, emojiPalettesViewAttr); + mCategoryIndicatorEnabled = emojiPalettesViewAttr.getBoolean( + R.styleable.EmojiPalettesView_categoryIndicatorEnabled, false); + mCategoryIndicatorDrawableResId = emojiPalettesViewAttr.getResourceId( + R.styleable.EmojiPalettesView_categoryIndicatorDrawable, 0); + mCategoryIndicatorBackgroundResId = emojiPalettesViewAttr.getResourceId( + R.styleable.EmojiPalettesView_categoryIndicatorBackground, 0); + mCategoryPageIndicatorColor = emojiPalettesViewAttr.getColor( + R.styleable.EmojiPalettesView_categoryPageIndicatorColor, 0); + mCategoryPageIndicatorBackground = emojiPalettesViewAttr.getColor( + R.styleable.EmojiPalettesView_categoryPageIndicatorBackground, 0); + emojiPalettesViewAttr.recycle(); + mDeleteKeyOnTouchListener = new DeleteKeyOnTouchListener(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + final Resources res = getContext().getResources(); + // The main keyboard expands to the entire this {@link KeyboardView}. + final int width = ResourceUtils.getDefaultKeyboardWidth(getContext()) + + getPaddingLeft() + getPaddingRight(); + final int height = ResourceUtils.getDefaultKeyboardHeight(res) + + res.getDimensionPixelSize(R.dimen.config_suggestions_strip_height) + + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension(width, height); + } + + private void addTab(final TabHost host, final int categoryId) { + final String tabId = EmojiCategory.getCategoryName(categoryId, 0 /* categoryPageId */); + final TabHost.TabSpec tspec = host.newTabSpec(tabId); + tspec.setContent(R.id.emoji_keyboard_dummy); + final ImageView iconView = (ImageView)LayoutInflater.from(getContext()).inflate( + R.layout.emoji_keyboard_tab_icon, null); + // TODO: Replace background color with its own setting rather than using the + // category page indicator background as a workaround. + iconView.setBackgroundColor(mCategoryPageIndicatorBackground); + iconView.setImageResource(mEmojiCategory.getCategoryTabIcon(categoryId)); + iconView.setContentDescription(mEmojiCategory.getAccessibilityDescription(categoryId)); + tspec.setIndicator(iconView); + host.addTab(tspec); + } + + @Override + protected void onFinishInflate() { + mTabHost = (TabHost)findViewById(R.id.emoji_category_tabhost); + mTabHost.setup(); + for (final EmojiCategory.CategoryProperties properties + : mEmojiCategory.getShownCategories()) { + addTab(mTabHost, properties.mCategoryId); + } + mTabHost.setOnTabChangedListener(this); + final TabWidget tabWidget = mTabHost.getTabWidget(); + tabWidget.setStripEnabled(mCategoryIndicatorEnabled); + if (mCategoryIndicatorEnabled) { + // On TabWidget's strip, what looks like an indicator is actually a background. + // And what looks like a background are actually left and right drawables. + tabWidget.setBackgroundResource(mCategoryIndicatorDrawableResId); + tabWidget.setLeftStripDrawable(mCategoryIndicatorBackgroundResId); + tabWidget.setRightStripDrawable(mCategoryIndicatorBackgroundResId); + } + + mEmojiPalettesAdapter = new EmojiPalettesAdapter(mEmojiCategory, this); + + mEmojiPager = (ViewPager)findViewById(R.id.emoji_keyboard_pager); + mEmojiPager.setAdapter(mEmojiPalettesAdapter); + mEmojiPager.setOnPageChangeListener(this); + mEmojiPager.setOffscreenPageLimit(0); + mEmojiPager.setPersistentDrawingCache(PERSISTENT_NO_CACHE); + mEmojiLayoutParams.setPagerProperties(mEmojiPager); + + mEmojiCategoryPageIndicatorView = + (EmojiCategoryPageIndicatorView)findViewById(R.id.emoji_category_page_id_view); + mEmojiCategoryPageIndicatorView.setColors( + mCategoryPageIndicatorColor, mCategoryPageIndicatorBackground); + mEmojiLayoutParams.setCategoryPageIdViewProperties(mEmojiCategoryPageIndicatorView); + + setCurrentCategoryId(mEmojiCategory.getCurrentCategoryId(), true /* force */); + + final LinearLayout actionBar = (LinearLayout)findViewById(R.id.emoji_action_bar); + mEmojiLayoutParams.setActionBarProperties(actionBar); + + // deleteKey depends only on OnTouchListener. + mDeleteKey = (ImageButton)findViewById(R.id.emoji_keyboard_delete); + mDeleteKey.setBackgroundResource(mFunctionalKeyBackgroundId); + mDeleteKey.setTag(Constants.CODE_DELETE); + mDeleteKey.setOnTouchListener(mDeleteKeyOnTouchListener); + + // {@link #mAlphabetKeyLeft}, {@link #mAlphabetKeyRight, and spaceKey depend on + // {@link View.OnClickListener} as well as {@link View.OnTouchListener}. + // {@link View.OnTouchListener} is used as the trigger of key-press, while + // {@link View.OnClickListener} is used as the trigger of key-release which does not occur + // if the event is canceled by moving off the finger from the view. + // The text on alphabet keys are set at + // {@link #startEmojiPalettes(String,int,float,Typeface)}. + mAlphabetKeyLeft = (TextView)findViewById(R.id.emoji_keyboard_alphabet_left); + mAlphabetKeyLeft.setBackgroundResource(mFunctionalKeyBackgroundId); + mAlphabetKeyLeft.setTag(Constants.CODE_ALPHA_FROM_EMOJI); + mAlphabetKeyLeft.setOnTouchListener(this); + mAlphabetKeyLeft.setOnClickListener(this); + mAlphabetKeyRight = (TextView)findViewById(R.id.emoji_keyboard_alphabet_right); + mAlphabetKeyRight.setBackgroundResource(mFunctionalKeyBackgroundId); + mAlphabetKeyRight.setTag(Constants.CODE_ALPHA_FROM_EMOJI); + mAlphabetKeyRight.setOnTouchListener(this); + mAlphabetKeyRight.setOnClickListener(this); + mSpacebar = findViewById(R.id.emoji_keyboard_space); + mSpacebar.setBackgroundResource(mSpacebarBackgroundId); + mSpacebar.setTag(Constants.CODE_SPACE); + mSpacebar.setOnTouchListener(this); + mSpacebar.setOnClickListener(this); + mEmojiLayoutParams.setKeyProperties(mSpacebar); + mSpacebarIcon = findViewById(R.id.emoji_keyboard_space_icon); + } + + @Override + public boolean dispatchTouchEvent(final MotionEvent ev) { + // Add here to the stack trace to nail down the {@link IllegalArgumentException} exception + // in MotionEvent that sporadically happens. + // TODO: Remove this override method once the issue has been addressed. + return super.dispatchTouchEvent(ev); + } + + @Override + public void onTabChanged(final String tabId) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback( + Constants.CODE_UNSPECIFIED, this); + final int categoryId = mEmojiCategory.getCategoryId(tabId); + setCurrentCategoryId(categoryId, false /* force */); + updateEmojiCategoryPageIdView(); + } + + @Override + public void onPageSelected(final int position) { + final Pair<Integer, Integer> newPos = + mEmojiCategory.getCategoryIdAndPageIdFromPagePosition(position); + setCurrentCategoryId(newPos.first /* categoryId */, false /* force */); + mEmojiCategory.setCurrentCategoryPageId(newPos.second /* categoryPageId */); + updateEmojiCategoryPageIdView(); + mCurrentPagerPosition = position; + } + + @Override + public void onPageScrollStateChanged(final int state) { + // Ignore this message. Only want the actual page selected. + } + + @Override + public void onPageScrolled(final int position, final float positionOffset, + final int positionOffsetPixels) { + mEmojiPalettesAdapter.onPageScrolled(); + final Pair<Integer, Integer> newPos = + mEmojiCategory.getCategoryIdAndPageIdFromPagePosition(position); + final int newCategoryId = newPos.first; + final int newCategorySize = mEmojiCategory.getCategoryPageSize(newCategoryId); + final int currentCategoryId = mEmojiCategory.getCurrentCategoryId(); + final int currentCategoryPageId = mEmojiCategory.getCurrentCategoryPageId(); + final int currentCategorySize = mEmojiCategory.getCurrentCategoryPageSize(); + if (newCategoryId == currentCategoryId) { + mEmojiCategoryPageIndicatorView.setCategoryPageId( + newCategorySize, newPos.second, positionOffset); + } else if (newCategoryId > currentCategoryId) { + mEmojiCategoryPageIndicatorView.setCategoryPageId( + currentCategorySize, currentCategoryPageId, positionOffset); + } else if (newCategoryId < currentCategoryId) { + mEmojiCategoryPageIndicatorView.setCategoryPageId( + currentCategorySize, currentCategoryPageId, positionOffset - 1); + } + } + + /** + * Called from {@link EmojiPageKeyboardView} through {@link android.view.View.OnTouchListener} + * interface to handle touch events from View-based elements such as the space bar. + * Note that this method is used only for observing {@link MotionEvent#ACTION_DOWN} to trigger + * {@link KeyboardActionListener#onPressKey}. {@link KeyboardActionListener#onReleaseKey} will + * be covered by {@link #onClick} as long as the event is not canceled. + */ + @Override + public boolean onTouch(final View v, final MotionEvent event) { + if (event.getActionMasked() != MotionEvent.ACTION_DOWN) { + return false; + } + final Object tag = v.getTag(); + if (!(tag instanceof Integer)) { + return false; + } + final int code = (Integer) tag; + mKeyboardActionListener.onPressKey( + code, 0 /* repeatCount */, true /* isSinglePointer */); + // It's important to return false here. Otherwise, {@link #onClick} and touch-down visual + // feedback stop working. + return false; + } + + /** + * Called from {@link EmojiPageKeyboardView} through {@link android.view.View.OnClickListener} + * interface to handle non-canceled touch-up events from View-based elements such as the space + * bar. + */ + @Override + public void onClick(View v) { + final Object tag = v.getTag(); + if (!(tag instanceof Integer)) { + return; + } + final int code = (Integer) tag; + mKeyboardActionListener.onCodeInput(code, NOT_A_COORDINATE, NOT_A_COORDINATE, + false /* isKeyRepeat */); + mKeyboardActionListener.onReleaseKey(code, false /* withSliding */); + } + + /** + * Called from {@link EmojiPageKeyboardView} through + * {@link EmojiPageKeyboardView.OnKeyEventListener} + * interface to handle touch events from non-View-based elements such as Emoji buttons. + */ + @Override + public void onPressKey(final Key key) { + final int code = key.getCode(); + mKeyboardActionListener.onPressKey(code, 0 /* repeatCount */, true /* isSinglePointer */); + } + + /** + * Called from {@link EmojiPageKeyboardView} through + * {@link EmojiPageKeyboardView.OnKeyEventListener} + * interface to handle touch events from non-View-based elements such as Emoji buttons. + */ + @Override + public void onReleaseKey(final Key key) { + mEmojiPalettesAdapter.addRecentKey(key); + mEmojiCategory.saveLastTypedCategoryPage(); + final int code = key.getCode(); + if (code == Constants.CODE_OUTPUT_TEXT) { + mKeyboardActionListener.onTextInput(key.getOutputText()); + } else { + mKeyboardActionListener.onCodeInput(code, NOT_A_COORDINATE, NOT_A_COORDINATE, + false /* isKeyRepeat */); + } + mKeyboardActionListener.onReleaseKey(code, false /* withSliding */); + } + + public void setHardwareAcceleratedDrawingEnabled(final boolean enabled) { + if (!enabled) return; + // TODO: Should use LAYER_TYPE_SOFTWARE when hardware acceleration is off? + setLayerType(LAYER_TYPE_HARDWARE, null); + } + + private static void setupAlphabetKey(final TextView alphabetKey, final String label, + final KeyDrawParams params) { + alphabetKey.setText(label); + alphabetKey.setTextColor(params.mFunctionalTextColor); + alphabetKey.setTextSize(TypedValue.COMPLEX_UNIT_PX, params.mLabelSize); + alphabetKey.setTypeface(params.mTypeface); + } + + public void startEmojiPalettes(final String switchToAlphaLabel, + final KeyVisualAttributes keyVisualAttr, + final KeyboardIconsSet iconSet) { + final int deleteIconResId = iconSet.getIconResourceId(KeyboardIconsSet.NAME_DELETE_KEY); + if (deleteIconResId != 0) { + mDeleteKey.setImageResource(deleteIconResId); + } + final int spacebarResId = iconSet.getIconResourceId(KeyboardIconsSet.NAME_SPACE_KEY); + if (spacebarResId != 0) { + // TODO: Remove this workaround to place the spacebar icon. + mSpacebarIcon.setBackgroundResource(spacebarResId); + } + final KeyDrawParams params = new KeyDrawParams(); + params.updateParams(mEmojiLayoutParams.getActionBarHeight(), keyVisualAttr); + setupAlphabetKey(mAlphabetKeyLeft, switchToAlphaLabel, params); + setupAlphabetKey(mAlphabetKeyRight, switchToAlphaLabel, params); + mEmojiPager.setAdapter(mEmojiPalettesAdapter); + mEmojiPager.setCurrentItem(mCurrentPagerPosition); + } + + public void stopEmojiPalettes() { + mEmojiPalettesAdapter.releaseCurrentKey(true /* withKeyRegistering */); + mEmojiPalettesAdapter.flushPendingRecentKeys(); + mEmojiPager.setAdapter(null); + } + + public void setKeyboardActionListener(final KeyboardActionListener listener) { + mKeyboardActionListener = listener; + mDeleteKeyOnTouchListener.setKeyboardActionListener(listener); + } + + private void updateEmojiCategoryPageIdView() { + if (mEmojiCategoryPageIndicatorView == null) { + return; + } + mEmojiCategoryPageIndicatorView.setCategoryPageId( + mEmojiCategory.getCurrentCategoryPageSize(), + mEmojiCategory.getCurrentCategoryPageId(), 0.0f /* offset */); + } + + private void setCurrentCategoryId(final int categoryId, final boolean force) { + final int oldCategoryId = mEmojiCategory.getCurrentCategoryId(); + if (oldCategoryId == categoryId && !force) { + return; + } + + if (oldCategoryId == EmojiCategory.ID_RECENTS) { + // Needs to save pending updates for recent keys when we get out of the recents + // category because we don't want to move the recent emojis around while the user + // is in the recents category. + mEmojiPalettesAdapter.flushPendingRecentKeys(); + } + + mEmojiCategory.setCurrentCategoryId(categoryId); + final int newTabId = mEmojiCategory.getTabIdFromCategoryId(categoryId); + final int newCategoryPageId = mEmojiCategory.getPageIdFromCategoryId(categoryId); + if (force || mEmojiCategory.getCategoryIdAndPageIdFromPagePosition( + mEmojiPager.getCurrentItem()).first != categoryId) { + mEmojiPager.setCurrentItem(newCategoryPageId, false /* smoothScroll */); + } + if (force || mTabHost.getCurrentTab() != newTabId) { + mTabHost.setCurrentTab(newTabId); + } + } + + private static class DeleteKeyOnTouchListener implements OnTouchListener { + private KeyboardActionListener mKeyboardActionListener = + KeyboardActionListener.EMPTY_LISTENER; + + public void setKeyboardActionListener(final KeyboardActionListener listener) { + mKeyboardActionListener = listener; + } + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + onTouchDown(v); + return true; + case MotionEvent.ACTION_MOVE: + final float x = event.getX(); + final float y = event.getY(); + if (x < 0.0f || v.getWidth() < x || y < 0.0f || v.getHeight() < y) { + // Stop generating key events once the finger moves away from the view area. + onTouchCanceled(v); + } + return true; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + onTouchUp(v); + return true; + } + return false; + } + + private void onTouchDown(final View v) { + mKeyboardActionListener.onPressKey(Constants.CODE_DELETE, + 0 /* repeatCount */, true /* isSinglePointer */); + v.setPressed(true /* pressed */); + } + + private void onTouchUp(final View v) { + mKeyboardActionListener.onCodeInput(Constants.CODE_DELETE, + NOT_A_COORDINATE, NOT_A_COORDINATE, false /* isKeyRepeat */); + mKeyboardActionListener.onReleaseKey(Constants.CODE_DELETE, false /* withSliding */); + v.setPressed(false /* pressed */); + } + + private void onTouchCanceled(final View v) { + v.setBackgroundColor(Color.TRANSPARENT); + } + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/AbstractDrawingPreview.java b/java/src/org/kelar/inputmethod/keyboard/internal/AbstractDrawingPreview.java new file mode 100644 index 000000000..3e3106ee0 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/AbstractDrawingPreview.java @@ -0,0 +1,84 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.graphics.Canvas; +import android.view.View; + +import org.kelar.inputmethod.keyboard.MainKeyboardView; +import org.kelar.inputmethod.keyboard.PointerTracker; + +import javax.annotation.Nonnull; + +/** + * Abstract base class for previews that are drawn on DrawingPreviewPlacerView, e.g., + * GestureFloatingTextDrawingPreview, GestureTrailsDrawingPreview, and + * SlidingKeyInputDrawingPreview. + */ +public abstract class AbstractDrawingPreview { + private View mDrawingView; + private boolean mPreviewEnabled; + private boolean mHasValidGeometry; + + public void setDrawingView(@Nonnull final DrawingPreviewPlacerView drawingView) { + mDrawingView = drawingView; + drawingView.addPreview(this); + } + + protected void invalidateDrawingView() { + if (mDrawingView != null) { + mDrawingView.invalidate(); + } + } + + protected final boolean isPreviewEnabled() { + return mPreviewEnabled && mHasValidGeometry; + } + + public final void setPreviewEnabled(final boolean enabled) { + mPreviewEnabled = enabled; + } + + /** + * Set {@link MainKeyboardView} geometry and position in the window of input method. + * The class that is overriding this method must call this super implementation. + * + * @param originCoords the top-left coordinates of the {@link MainKeyboardView} in + * the input method window coordinate-system. This is unused but has a point in an + * extended class, such as {@link GestureTrailsDrawingPreview}. + * @param width the width of {@link MainKeyboardView}. + * @param height the height of {@link MainKeyboardView}. + */ + public void setKeyboardViewGeometry(@Nonnull final int[] originCoords, final int width, + final int height) { + mHasValidGeometry = (width > 0 && height > 0); + } + + public abstract void onDeallocateMemory(); + + /** + * Draws the preview + * @param canvas The canvas where the preview is drawn. + */ + public abstract void drawPreview(@Nonnull final Canvas canvas); + + /** + * Set the position of the preview. + * @param tracker The new location of the preview is based on the points in PointerTracker. + */ + public abstract void setPreviewPosition(@Nonnull final PointerTracker tracker); +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/AlphabetShiftState.java b/java/src/org/kelar/inputmethod/keyboard/internal/AlphabetShiftState.java new file mode 100644 index 000000000..2ea6e973a --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/AlphabetShiftState.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; + +public final class AlphabetShiftState { + private static final String TAG = AlphabetShiftState.class.getSimpleName(); + private static final boolean DEBUG = false; + + private static final int UNSHIFTED = 0; + private static final int MANUAL_SHIFTED = 1; + private static final int MANUAL_SHIFTED_FROM_AUTO = 2; + private static final int AUTOMATIC_SHIFTED = 3; + private static final int SHIFT_LOCKED = 4; + private static final int SHIFT_LOCK_SHIFTED = 5; + + private int mState = UNSHIFTED; + + public void setShifted(boolean newShiftState) { + final int oldState = mState; + if (newShiftState) { + switch (oldState) { + case UNSHIFTED: + mState = MANUAL_SHIFTED; + break; + case AUTOMATIC_SHIFTED: + mState = MANUAL_SHIFTED_FROM_AUTO; + break; + case SHIFT_LOCKED: + mState = SHIFT_LOCK_SHIFTED; + break; + } + } else { + switch (oldState) { + case MANUAL_SHIFTED: + case MANUAL_SHIFTED_FROM_AUTO: + case AUTOMATIC_SHIFTED: + mState = UNSHIFTED; + break; + case SHIFT_LOCK_SHIFTED: + mState = SHIFT_LOCKED; + break; + } + } + if (DEBUG) + Log.d(TAG, "setShifted(" + newShiftState + "): " + toString(oldState) + " > " + this); + } + + public void setShiftLocked(boolean newShiftLockState) { + final int oldState = mState; + if (newShiftLockState) { + switch (oldState) { + case UNSHIFTED: + case MANUAL_SHIFTED: + case MANUAL_SHIFTED_FROM_AUTO: + case AUTOMATIC_SHIFTED: + mState = SHIFT_LOCKED; + break; + } + } else { + mState = UNSHIFTED; + } + if (DEBUG) + Log.d(TAG, "setShiftLocked(" + newShiftLockState + "): " + toString(oldState) + + " > " + this); + } + + public void setAutomaticShifted() { + final int oldState = mState; + mState = AUTOMATIC_SHIFTED; + if (DEBUG) + Log.d(TAG, "setAutomaticShifted: " + toString(oldState) + " > " + this); + } + + public boolean isShiftedOrShiftLocked() { + return mState != UNSHIFTED; + } + + public boolean isShiftLocked() { + return mState == SHIFT_LOCKED || mState == SHIFT_LOCK_SHIFTED; + } + + public boolean isShiftLockShifted() { + return mState == SHIFT_LOCK_SHIFTED; + } + + public boolean isAutomaticShifted() { + return mState == AUTOMATIC_SHIFTED; + } + + public boolean isManualShifted() { + return mState == MANUAL_SHIFTED || mState == MANUAL_SHIFTED_FROM_AUTO + || mState == SHIFT_LOCK_SHIFTED; + } + + public boolean isManualShiftedFromAutomaticShifted() { + return mState == MANUAL_SHIFTED_FROM_AUTO; + } + + @Override + public String toString() { + return toString(mState); + } + + private static String toString(int state) { + switch (state) { + case UNSHIFTED: return "UNSHIFTED"; + case MANUAL_SHIFTED: return "MANUAL_SHIFTED"; + case MANUAL_SHIFTED_FROM_AUTO: return "MANUAL_SHIFTED_FROM_AUTO"; + case AUTOMATIC_SHIFTED: return "AUTOMATIC_SHIFTED"; + case SHIFT_LOCKED: return "SHIFT_LOCKED"; + case SHIFT_LOCK_SHIFTED: return "SHIFT_LOCK_SHIFTED"; + default: return "UNKNOWN"; + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/BatchInputArbiter.java b/java/src/org/kelar/inputmethod/keyboard/internal/BatchInputArbiter.java new file mode 100644 index 000000000..e786fdb95 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/BatchInputArbiter.java @@ -0,0 +1,181 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.InputPointers; + +/** + * This class arbitrates batch input. + * An instance of this class holds a {@link GestureStrokeRecognitionPoints}. + * And it arbitrates multiple strokes gestured by multiple fingers and aggregates those gesture + * points into one batch input. + */ +public class BatchInputArbiter { + public interface BatchInputArbiterListener { + public void onStartBatchInput(); + public void onUpdateBatchInput( + final InputPointers aggregatedPointers, final long moveEventTime); + public void onStartUpdateBatchInputTimer(); + public void onEndBatchInput(final InputPointers aggregatedPointers, final long upEventTime); + } + + // The starting time of the first stroke of a gesture input. + private static long sGestureFirstDownTime; + // The {@link InputPointers} that includes all events of a gesture input. + private static final InputPointers sAggregatedPointers = new InputPointers( + Constants.DEFAULT_GESTURE_POINTS_CAPACITY); + private static int sLastRecognitionPointSize = 0; // synchronized using sAggregatedPointers + private static long sLastRecognitionTime = 0; // synchronized using sAggregatedPointers + + private final GestureStrokeRecognitionPoints mRecognitionPoints; + + public BatchInputArbiter(final int pointerId, final GestureStrokeRecognitionParams params) { + mRecognitionPoints = new GestureStrokeRecognitionPoints(pointerId, params); + } + + public void setKeyboardGeometry(final int keyWidth, final int keyboardHeight) { + mRecognitionPoints.setKeyboardGeometry(keyWidth, keyboardHeight); + } + + /** + * Calculate elapsed time since the first gesture down. + * @param eventTime the time of this event. + * @return the elapsed time in millisecond from the first gesture down. + */ + public int getElapsedTimeSinceFirstDown(final long eventTime) { + return (int)(eventTime - sGestureFirstDownTime); + } + + /** + * Add a down event point. + * @param x the x-coordinate of this down event. + * @param y the y-coordinate of this down event. + * @param downEventTime the time of this down event. + * @param lastLetterTypingTime the last typing input time. + * @param activePointerCount the number of active pointers when this pointer down event occurs. + */ + public void addDownEventPoint(final int x, final int y, final long downEventTime, + final long lastLetterTypingTime, final int activePointerCount) { + if (activePointerCount == 1) { + sGestureFirstDownTime = downEventTime; + } + final int elapsedTimeSinceFirstDown = getElapsedTimeSinceFirstDown(downEventTime); + final int elapsedTimeSinceLastTyping = (int)(downEventTime - lastLetterTypingTime); + mRecognitionPoints.addDownEventPoint( + x, y, elapsedTimeSinceFirstDown, elapsedTimeSinceLastTyping); + } + + /** + * Add a move event point. + * @param x the x-coordinate of this move event. + * @param y the y-coordinate of this move event. + * @param moveEventTime the time of this move event. + * @param isMajorEvent false if this is a historical move event. + * @param listener {@link BatchInputArbiterListener#onStartUpdateBatchInputTimer()} of this + * <code>listener</code> may be called if enough move points have been added. + * @return true if this move event occurs on the valid gesture area. + */ + public boolean addMoveEventPoint(final int x, final int y, final long moveEventTime, + final boolean isMajorEvent, final BatchInputArbiterListener listener) { + final int beforeLength = mRecognitionPoints.getLength(); + final boolean onValidArea = mRecognitionPoints.addEventPoint( + x, y, getElapsedTimeSinceFirstDown(moveEventTime), isMajorEvent); + if (mRecognitionPoints.getLength() > beforeLength) { + listener.onStartUpdateBatchInputTimer(); + } + return onValidArea; + } + + /** + * Determine whether the batch input has started or not. + * @param listener {@link BatchInputArbiterListener#onStartBatchInput()} of this + * <code>listener</code> will be called when the batch input has started successfully. + * @return true if the batch input has started successfully. + */ + public boolean mayStartBatchInput(final BatchInputArbiterListener listener) { + if (!mRecognitionPoints.isStartOfAGesture()) { + return false; + } + synchronized (sAggregatedPointers) { + sAggregatedPointers.reset(); + sLastRecognitionPointSize = 0; + sLastRecognitionTime = 0; + listener.onStartBatchInput(); + } + return true; + } + + /** + * Add synthetic move event point. After adding the point, + * {@link #updateBatchInput(long,BatchInputArbiterListener)} will be called internally. + * @param syntheticMoveEventTime the synthetic move event time. + * @param listener the listener to be passed to + * {@link #updateBatchInput(long,BatchInputArbiterListener)}. + */ + public void updateBatchInputByTimer(final long syntheticMoveEventTime, + final BatchInputArbiterListener listener) { + mRecognitionPoints.duplicateLastPointWith( + getElapsedTimeSinceFirstDown(syntheticMoveEventTime)); + updateBatchInput(syntheticMoveEventTime, listener); + } + + /** + * Determine whether we have enough gesture points to lookup dictionary. + * @param moveEventTime the time of this move event. + * @param listener {@link BatchInputArbiterListener#onUpdateBatchInput(InputPointers,long)} of + * this <code>listener</code> will be called when enough event points we have. Also + * {@link BatchInputArbiterListener#onStartUpdateBatchInputTimer()} will be called to have + * possible future synthetic move event. + */ + public void updateBatchInput(final long moveEventTime, + final BatchInputArbiterListener listener) { + synchronized (sAggregatedPointers) { + mRecognitionPoints.appendIncrementalBatchPoints(sAggregatedPointers); + final int size = sAggregatedPointers.getPointerSize(); + if (size > sLastRecognitionPointSize && mRecognitionPoints.hasRecognitionTimePast( + moveEventTime, sLastRecognitionTime)) { + listener.onUpdateBatchInput(sAggregatedPointers, moveEventTime); + listener.onStartUpdateBatchInputTimer(); + // The listener may change the size of the pointers (when auto-committing + // for example), so we need to get the size from the pointers again. + sLastRecognitionPointSize = sAggregatedPointers.getPointerSize(); + sLastRecognitionTime = moveEventTime; + } + } + } + + /** + * Determine whether the batch input has ended successfully or continues. + * @param upEventTime the time of this up event. + * @param activePointerCount the number of active pointers when this pointer up event occurs. + * @param listener {@link BatchInputArbiterListener#onEndBatchInput(InputPointers,long)} of this + * <code>listener</code> will be called when the batch input has started successfully. + * @return true if the batch input has ended successfully. + */ + public boolean mayEndBatchInput(final long upEventTime, final int activePointerCount, + final BatchInputArbiterListener listener) { + synchronized (sAggregatedPointers) { + mRecognitionPoints.appendAllBatchPoints(sAggregatedPointers); + if (activePointerCount == 1) { + listener.onEndBatchInput(sAggregatedPointers, upEventTime); + return true; + } + } + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/BogusMoveEventDetector.java b/java/src/org/kelar/inputmethod/keyboard/internal/BogusMoveEventDetector.java new file mode 100644 index 000000000..bed7cb971 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/BogusMoveEventDetector.java @@ -0,0 +1,115 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.Resources; +import android.util.DisplayMetrics; +import android.util.Log; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.define.DebugFlags; + +// This hack is applied to certain classes of tablets. +public final class BogusMoveEventDetector { + private static final String TAG = BogusMoveEventDetector.class.getSimpleName(); + private static final boolean DEBUG_MODE = DebugFlags.DEBUG_ENABLED; + + // Move these thresholds to resource. + // These thresholds' unit is a diagonal length of a key. + private static final float BOGUS_MOVE_ACCUMULATED_DISTANCE_THRESHOLD = 0.53f; + private static final float BOGUS_MOVE_RADIUS_THRESHOLD = 1.14f; + + private static boolean sNeedsProximateBogusDownMoveUpEventHack; + + public static void init(final Resources res) { + // The proximate bogus down move up event hack is needed for a device such like, + // 1) is large tablet, or 2) is small tablet and the screen density is less than hdpi. + // Though it seems odd to use screen density as criteria of the quality of the touch + // screen, the small table that has a less density screen than hdpi most likely has been + // made with the touch screen that needs the hack. + final int screenMetrics = res.getInteger(R.integer.config_screen_metrics); + final boolean isLargeTablet = (screenMetrics == Constants.SCREEN_METRICS_LARGE_TABLET); + final boolean isSmallTablet = (screenMetrics == Constants.SCREEN_METRICS_SMALL_TABLET); + final int densityDpi = res.getDisplayMetrics().densityDpi; + final boolean hasLowDensityScreen = (densityDpi < DisplayMetrics.DENSITY_HIGH); + final boolean needsTheHack = isLargeTablet || (isSmallTablet && hasLowDensityScreen); + if (DEBUG_MODE) { + final int sw = res.getConfiguration().smallestScreenWidthDp; + Log.d(TAG, "needsProximateBogusDownMoveUpEventHack=" + needsTheHack + + " smallestScreenWidthDp=" + sw + " densityDpi=" + densityDpi + + " screenMetrics=" + screenMetrics); + } + sNeedsProximateBogusDownMoveUpEventHack = needsTheHack; + } + + private int mAccumulatedDistanceThreshold; + private int mRadiusThreshold; + + // Accumulated distance from actual and artificial down keys. + /* package */ int mAccumulatedDistanceFromDownKey; + private int mActualDownX; + private int mActualDownY; + + public void setKeyboardGeometry(final int keyWidth, final int keyHeight) { + final float keyDiagonal = (float)Math.hypot(keyWidth, keyHeight); + mAccumulatedDistanceThreshold = (int)( + keyDiagonal * BOGUS_MOVE_ACCUMULATED_DISTANCE_THRESHOLD); + mRadiusThreshold = (int)(keyDiagonal * BOGUS_MOVE_RADIUS_THRESHOLD); + } + + public void onActualDownEvent(final int x, final int y) { + mActualDownX = x; + mActualDownY = y; + } + + public void onDownKey() { + mAccumulatedDistanceFromDownKey = 0; + } + + public void onMoveKey(final int distance) { + mAccumulatedDistanceFromDownKey += distance; + } + + public boolean hasTraveledLongDistance(final int x, final int y) { + if (!sNeedsProximateBogusDownMoveUpEventHack) { + return false; + } + final int dx = Math.abs(x - mActualDownX); + final int dy = Math.abs(y - mActualDownY); + // A bogus move event should be a horizontal movement. A vertical movement might be + // a sloppy typing and should be ignored. + return dx >= dy && mAccumulatedDistanceFromDownKey >= mAccumulatedDistanceThreshold; + } + + public int getAccumulatedDistanceFromDownKey() { + return mAccumulatedDistanceFromDownKey; + } + + public int getDistanceFromDownEvent(final int x, final int y) { + return getDistance(x, y, mActualDownX, mActualDownY); + } + + private static int getDistance(final int x1, final int y1, final int x2, final int y2) { + return (int)Math.hypot(x1 - x2, y1 - y2); + } + + public boolean isCloseToActualDownEvent(final int x, final int y) { + return sNeedsProximateBogusDownMoveUpEventHack + && getDistanceFromDownEvent(x, y) < mRadiusThreshold; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/CodesArrayParser.java b/java/src/org/kelar/inputmethod/keyboard/internal/CodesArrayParser.java new file mode 100644 index 000000000..d5d3c96b7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/CodesArrayParser.java @@ -0,0 +1,107 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import android.text.TextUtils; + +/** + * The string parser of codesArray specification for <GridRows />. The attribute codesArray is an + * array of string. + * Each element of the array defines a key label by specifying a code point as a hexadecimal string. + * A key label may consist of multiple code points separated by comma. + * Each element of the array optionally can have an output text definition after vertical bar + * marker. An output text may consist of multiple code points separated by comma. + * The format of the codesArray element should be: + * <pre> + * label1[,label2]*(|outputText1[,outputText2]*(|minSupportSdkVersion)?)? + * </pre> + */ +// TODO: Write unit tests for this class. +public final class CodesArrayParser { + // Constants for parsing. + private static final char COMMA = Constants.CODE_COMMA; + private static final String COMMA_REGEX = StringUtils.newSingleCodePointString(COMMA); + private static final String VERTICAL_BAR_REGEX = // "\\|" + new String(new char[] { Constants.CODE_BACKSLASH, Constants.CODE_VERTICAL_BAR }); + private static final int BASE_HEX = 16; + + private CodesArrayParser() { + // This utility class is not publicly instantiable. + } + + private static String getLabelSpec(final String codesArraySpec) { + final String[] strs = codesArraySpec.split(VERTICAL_BAR_REGEX, -1); + if (strs.length <= 1) { + return codesArraySpec; + } + return strs[0]; + } + + public static String parseLabel(final String codesArraySpec) { + final String labelSpec = getLabelSpec(codesArraySpec); + final StringBuilder sb = new StringBuilder(); + for (final String codeInHex : labelSpec.split(COMMA_REGEX)) { + final int codePoint = Integer.parseInt(codeInHex, BASE_HEX); + sb.appendCodePoint(codePoint); + } + return sb.toString(); + } + + private static String getCodeSpec(final String codesArraySpec) { + final String[] strs = codesArraySpec.split(VERTICAL_BAR_REGEX, -1); + if (strs.length <= 1) { + return codesArraySpec; + } + return TextUtils.isEmpty(strs[1]) ? strs[0] : strs[1]; + } + + public static int getMinSupportSdkVersion(final String codesArraySpec) { + final String[] strs = codesArraySpec.split(VERTICAL_BAR_REGEX, -1); + if (strs.length <= 2) { + return 0; + } + try { + return Integer.parseInt(strs[2]); + } catch (NumberFormatException e) { + return 0; + } + } + + public static int parseCode(final String codesArraySpec) { + final String codeSpec = getCodeSpec(codesArraySpec); + if (codeSpec.indexOf(COMMA) < 0) { + return Integer.parseInt(codeSpec, BASE_HEX); + } + return Constants.CODE_OUTPUT_TEXT; + } + + public static String parseOutputText(final String codesArraySpec) { + final String codeSpec = getCodeSpec(codesArraySpec); + if (codeSpec.indexOf(COMMA) < 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (final String codeInHex : codeSpec.split(COMMA_REGEX)) { + final int codePoint = Integer.parseInt(codeInHex, BASE_HEX); + sb.appendCodePoint(codePoint); + } + return sb.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/DrawingPreviewPlacerView.java b/java/src/org/kelar/inputmethod/keyboard/internal/DrawingPreviewPlacerView.java new file mode 100644 index 000000000..fcdc0f668 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/DrawingPreviewPlacerView.java @@ -0,0 +1,88 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.util.AttributeSet; +import android.widget.RelativeLayout; + +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +import java.util.ArrayList; + +public final class DrawingPreviewPlacerView extends RelativeLayout { + private final int[] mKeyboardViewOrigin = CoordinateUtils.newInstance(); + + private final ArrayList<AbstractDrawingPreview> mPreviews = new ArrayList<>(); + + public DrawingPreviewPlacerView(final Context context, final AttributeSet attrs) { + super(context, attrs); + setWillNotDraw(false); + } + + public void setHardwareAcceleratedDrawingEnabled(final boolean enabled) { + if (!enabled) return; + final Paint layerPaint = new Paint(); + layerPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)); + setLayerType(LAYER_TYPE_HARDWARE, layerPaint); + } + + public void addPreview(final AbstractDrawingPreview preview) { + if (mPreviews.indexOf(preview) < 0) { + mPreviews.add(preview); + } + } + + public void setKeyboardViewGeometry(final int[] originCoords, final int width, + final int height) { + CoordinateUtils.copy(mKeyboardViewOrigin, originCoords); + final int count = mPreviews.size(); + for (int i = 0; i < count; i++) { + mPreviews.get(i).setKeyboardViewGeometry(originCoords, width, height); + } + } + + public void deallocateMemory() { + final int count = mPreviews.size(); + for (int i = 0; i < count; i++) { + mPreviews.get(i).onDeallocateMemory(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + deallocateMemory(); + } + + @Override + public void onDraw(final Canvas canvas) { + super.onDraw(canvas); + final int originX = CoordinateUtils.x(mKeyboardViewOrigin); + final int originY = CoordinateUtils.y(mKeyboardViewOrigin); + canvas.translate(originX, originY); + final int count = mPreviews.size(); + for (int i = 0; i < count; i++) { + mPreviews.get(i).drawPreview(canvas); + } + canvas.translate(-originX, -originY); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/DrawingProxy.java b/java/src/org/kelar/inputmethod/keyboard/internal/DrawingProxy.java new file mode 100644 index 000000000..d56e5d62a --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/DrawingProxy.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.MoreKeysPanel; +import org.kelar.inputmethod.keyboard.PointerTracker; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public interface DrawingProxy { + /** + * Called when a key is being pressed. + * @param key the {@link Key} that is being pressed. + * @param withPreview true if key popup preview should be displayed. + */ + public void onKeyPressed(@Nonnull Key key, boolean withPreview); + + /** + * Called when a key is being released. + * @param key the {@link Key} that is being released. + * @param withAnimation when true, key popup preview should be dismissed with animation. + */ + public void onKeyReleased(@Nonnull Key key, boolean withAnimation); + + /** + * Start showing more keys keyboard of a key that is being long pressed. + * @param key the {@link Key} that is being long pressed and showing more keys keyboard. + * @param tracker the {@link PointerTracker} that detects this long pressing. + * @return {@link MoreKeysPanel} that is being shown. null if there is no need to show more keys + * keyboard. + */ + @Nullable + public MoreKeysPanel showMoreKeysKeyboard(@Nonnull Key key, @Nonnull PointerTracker tracker); + + /** + * Start a while-typing-animation. + * @param fadeInOrOut {@link #FADE_IN} starts while-typing-fade-in animation. + * {@link #FADE_OUT} starts while-typing-fade-out animation. + */ + public void startWhileTypingAnimation(int fadeInOrOut); + public static final int FADE_IN = 0; + public static final int FADE_OUT = 1; + + /** + * Show sliding-key input preview. + * @param tracker the {@link PointerTracker} that is currently doing the sliding-key input. + * null to dismiss the sliding-key input preview. + */ + public void showSlidingKeyInputPreview(@Nullable PointerTracker tracker); + + /** + * Show gesture trails. + * @param tracker the {@link PointerTracker} whose gesture trail will be shown. + * @param showsFloatingPreviewText when true, a gesture floating preview text will be shown + * with this <code>tracker</code>'s trail. + */ + public void showGestureTrail(@Nonnull PointerTracker tracker, boolean showsFloatingPreviewText); + + /** + * Dismiss a gesture floating preview text without delay. + */ + public void dismissGestureFloatingPreviewTextWithoutDelay(); +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureEnabler.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureEnabler.java new file mode 100644 index 000000000..524bf136a --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureEnabler.java @@ -0,0 +1,54 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; + +public final class GestureEnabler { + /** True if we should handle gesture events. */ + private boolean mShouldHandleGesture; + private boolean mMainDictionaryAvailable; + private boolean mGestureHandlingEnabledByInputField; + private boolean mGestureHandlingEnabledByUser; + + private void updateGestureHandlingMode() { + mShouldHandleGesture = mMainDictionaryAvailable + && mGestureHandlingEnabledByInputField + && mGestureHandlingEnabledByUser + && !AccessibilityUtils.getInstance().isTouchExplorationEnabled(); + } + + // Note that this method is called from a non-UI thread. + public void setMainDictionaryAvailability(final boolean mainDictionaryAvailable) { + mMainDictionaryAvailable = mainDictionaryAvailable; + updateGestureHandlingMode(); + } + + public void setGestureHandlingEnabledByUser(final boolean gestureHandlingEnabledByUser) { + mGestureHandlingEnabledByUser = gestureHandlingEnabledByUser; + updateGestureHandlingMode(); + } + + public void setPasswordMode(final boolean passwordMode) { + mGestureHandlingEnabledByInputField = !passwordMode; + updateGestureHandlingMode(); + } + + public boolean shouldHandleGesture() { + return mShouldHandleGesture; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureFloatingTextDrawingPreview.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureFloatingTextDrawingPreview.java new file mode 100644 index 000000000..762e082fe --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureFloatingTextDrawingPreview.java @@ -0,0 +1,184 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Rect; +import android.graphics.RectF; +import android.text.TextUtils; + +import org.kelar.inputmethod.keyboard.PointerTracker; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +import javax.annotation.Nonnull; + +/** + * The class for single gesture preview text. The class for multiple gesture preview text will be + * derived from it. + * + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewTextSize + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewTextColor + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewTextOffset + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewColor + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewHorizontalPadding + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewVerticalPadding + * @attr ref android.R.styleable#KeyboardView_gestureFloatingPreviewRoundRadius + */ +public class GestureFloatingTextDrawingPreview extends AbstractDrawingPreview { + protected static final class GesturePreviewTextParams { + public final int mGesturePreviewTextOffset; + public final int mGesturePreviewTextHeight; + public final float mGesturePreviewHorizontalPadding; + public final float mGesturePreviewVerticalPadding; + public final float mGesturePreviewRoundRadius; + public final int mDisplayWidth; + + private final int mGesturePreviewTextSize; + private final int mGesturePreviewTextColor; + private final int mGesturePreviewColor; + private final Paint mPaint = new Paint(); + + private static final char[] TEXT_HEIGHT_REFERENCE_CHAR = { 'M' }; + + public GesturePreviewTextParams(final TypedArray mainKeyboardViewAttr) { + mGesturePreviewTextSize = mainKeyboardViewAttr.getDimensionPixelSize( + R.styleable.MainKeyboardView_gestureFloatingPreviewTextSize, 0); + mGesturePreviewTextColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_gestureFloatingPreviewTextColor, 0); + mGesturePreviewTextOffset = mainKeyboardViewAttr.getDimensionPixelOffset( + R.styleable.MainKeyboardView_gestureFloatingPreviewTextOffset, 0); + mGesturePreviewColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_gestureFloatingPreviewColor, 0); + mGesturePreviewHorizontalPadding = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureFloatingPreviewHorizontalPadding, 0.0f); + mGesturePreviewVerticalPadding = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureFloatingPreviewVerticalPadding, 0.0f); + mGesturePreviewRoundRadius = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureFloatingPreviewRoundRadius, 0.0f); + mDisplayWidth = mainKeyboardViewAttr.getResources().getDisplayMetrics().widthPixels; + + final Paint textPaint = getTextPaint(); + final Rect textRect = new Rect(); + textPaint.getTextBounds(TEXT_HEIGHT_REFERENCE_CHAR, 0, 1, textRect); + mGesturePreviewTextHeight = textRect.height(); + } + + public Paint getTextPaint() { + mPaint.setAntiAlias(true); + mPaint.setTextAlign(Align.CENTER); + mPaint.setTextSize(mGesturePreviewTextSize); + mPaint.setColor(mGesturePreviewTextColor); + return mPaint; + } + + public Paint getBackgroundPaint() { + mPaint.setColor(mGesturePreviewColor); + return mPaint; + } + } + + private final GesturePreviewTextParams mParams; + private final RectF mGesturePreviewRectangle = new RectF(); + private int mPreviewTextX; + private int mPreviewTextY; + private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); + private final int[] mLastPointerCoords = CoordinateUtils.newInstance(); + + public GestureFloatingTextDrawingPreview(final TypedArray mainKeyboardViewAttr) { + mParams = new GesturePreviewTextParams(mainKeyboardViewAttr); + } + + @Override + public void onDeallocateMemory() { + // Nothing to do here. + } + + public void dismissGestureFloatingPreviewText() { + setSuggetedWords(SuggestedWords.getEmptyInstance()); + } + + public void setSuggetedWords(@Nonnull final SuggestedWords suggestedWords) { + if (!isPreviewEnabled()) { + return; + } + mSuggestedWords = suggestedWords; + updatePreviewPosition(); + } + + @Override + public void setPreviewPosition(final PointerTracker tracker) { + if (!isPreviewEnabled()) { + return; + } + tracker.getLastCoordinates(mLastPointerCoords); + updatePreviewPosition(); + } + + /** + * Draws gesture preview text + * @param canvas The canvas where preview text is drawn. + */ + @Override + public void drawPreview(final Canvas canvas) { + if (!isPreviewEnabled() || mSuggestedWords.isEmpty() + || TextUtils.isEmpty(mSuggestedWords.getWord(0))) { + return; + } + final float round = mParams.mGesturePreviewRoundRadius; + canvas.drawRoundRect( + mGesturePreviewRectangle, round, round, mParams.getBackgroundPaint()); + final String text = mSuggestedWords.getWord(0); + canvas.drawText(text, mPreviewTextX, mPreviewTextY, mParams.getTextPaint()); + } + + /** + * Updates gesture preview text position based on mLastPointerCoords. + */ + protected void updatePreviewPosition() { + if (mSuggestedWords.isEmpty() || TextUtils.isEmpty(mSuggestedWords.getWord(0))) { + invalidateDrawingView(); + return; + } + final String text = mSuggestedWords.getWord(0); + + final RectF rectangle = mGesturePreviewRectangle; + + final int textHeight = mParams.mGesturePreviewTextHeight; + final float textWidth = mParams.getTextPaint().measureText(text); + final float hPad = mParams.mGesturePreviewHorizontalPadding; + final float vPad = mParams.mGesturePreviewVerticalPadding; + final float rectWidth = textWidth + hPad * 2.0f; + final float rectHeight = textHeight + vPad * 2.0f; + + final float rectX = Math.min( + Math.max(CoordinateUtils.x(mLastPointerCoords) - rectWidth / 2.0f, 0.0f), + mParams.mDisplayWidth - rectWidth); + final float rectY = CoordinateUtils.y(mLastPointerCoords) + - mParams.mGesturePreviewTextOffset - rectHeight; + rectangle.set(rectX, rectY, rectX + rectWidth, rectY + rectHeight); + + mPreviewTextX = (int)(rectX + hPad + textWidth / 2.0f); + mPreviewTextY = (int)(rectY + vPad) + textHeight; + // TODO: Should narrow the invalidate region. + invalidateDrawingView(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingParams.java new file mode 100644 index 000000000..c9231028f --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingParams.java @@ -0,0 +1,58 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; + +import org.kelar.inputmethod.latin.R; + +/** + * This class holds parameters to control how a gesture stroke is sampled and drawn on the screen. + * + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailMinSamplingDistance + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailMaxInterpolationAngularThreshold + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailMaxInterpolationDistanceThreshold + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailMaxInterpolationSegments + */ +public final class GestureStrokeDrawingParams { + public final double mMinSamplingDistance; // in pixel + public final double mMaxInterpolationAngularThreshold; // in radian + public final double mMaxInterpolationDistanceThreshold; // in pixel + public final int mMaxInterpolationSegments; + + private static final float DEFAULT_MIN_SAMPLING_DISTANCE = 0.0f; // dp + private static final int DEFAULT_MAX_INTERPOLATION_ANGULAR_THRESHOLD = 15; // in degree + private static final float DEFAULT_MAX_INTERPOLATION_DISTANCE_THRESHOLD = 0.0f; // dp + private static final int DEFAULT_MAX_INTERPOLATION_SEGMENTS = 4; + + public GestureStrokeDrawingParams(final TypedArray mainKeyboardViewAttr) { + mMinSamplingDistance = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureTrailMinSamplingDistance, + DEFAULT_MIN_SAMPLING_DISTANCE); + final int interpolationAngularDegree = mainKeyboardViewAttr.getInteger(R.styleable + .MainKeyboardView_gestureTrailMaxInterpolationAngularThreshold, 0); + mMaxInterpolationAngularThreshold = (interpolationAngularDegree <= 0) + ? Math.toRadians(DEFAULT_MAX_INTERPOLATION_ANGULAR_THRESHOLD) + : Math.toRadians(interpolationAngularDegree); + mMaxInterpolationDistanceThreshold = mainKeyboardViewAttr.getDimension(R.styleable + .MainKeyboardView_gestureTrailMaxInterpolationDistanceThreshold, + DEFAULT_MAX_INTERPOLATION_DISTANCE_THRESHOLD); + mMaxInterpolationSegments = mainKeyboardViewAttr.getInteger( + R.styleable.MainKeyboardView_gestureTrailMaxInterpolationSegments, + DEFAULT_MAX_INTERPOLATION_SEGMENTS); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingPoints.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingPoints.java new file mode 100644 index 000000000..1bb10eafb --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingPoints.java @@ -0,0 +1,197 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.latin.common.ResizableIntArray; + +/** + * This class holds drawing points to represent a gesture stroke on the screen. + */ +public final class GestureStrokeDrawingPoints { + public static final int PREVIEW_CAPACITY = 256; + + private final ResizableIntArray mPreviewEventTimes = new ResizableIntArray(PREVIEW_CAPACITY); + private final ResizableIntArray mPreviewXCoordinates = new ResizableIntArray(PREVIEW_CAPACITY); + private final ResizableIntArray mPreviewYCoordinates = new ResizableIntArray(PREVIEW_CAPACITY); + + private final GestureStrokeDrawingParams mDrawingParams; + + private int mStrokeId; + private int mLastPreviewSize; + private final HermiteInterpolator mInterpolator = new HermiteInterpolator(); + private int mLastInterpolatedPreviewIndex; + + private int mLastX; + private int mLastY; + private double mDistanceFromLastSample; + + public GestureStrokeDrawingPoints(final GestureStrokeDrawingParams drawingParams) { + mDrawingParams = drawingParams; + } + + private void reset() { + mStrokeId++; + mLastPreviewSize = 0; + mLastInterpolatedPreviewIndex = 0; + mPreviewEventTimes.setLength(0); + mPreviewXCoordinates.setLength(0); + mPreviewYCoordinates.setLength(0); + } + + public int getGestureStrokeId() { + return mStrokeId; + } + + public void onDownEvent(final int x, final int y, final int elapsedTimeSinceFirstDown) { + reset(); + onMoveEvent(x, y, elapsedTimeSinceFirstDown); + } + + private boolean needsSampling(final int x, final int y) { + mDistanceFromLastSample += Math.hypot(x - mLastX, y - mLastY); + mLastX = x; + mLastY = y; + final boolean isDownEvent = (mPreviewEventTimes.getLength() == 0); + if (mDistanceFromLastSample >= mDrawingParams.mMinSamplingDistance || isDownEvent) { + mDistanceFromLastSample = 0.0d; + return true; + } + return false; + } + + public void onMoveEvent(final int x, final int y, final int elapsedTimeSinceFirstDown) { + if (needsSampling(x, y)) { + mPreviewEventTimes.add(elapsedTimeSinceFirstDown); + mPreviewXCoordinates.add(x); + mPreviewYCoordinates.add(y); + } + } + + /** + * Append sampled preview points. + * + * @param eventTimes the event time array of gesture trail to be drawn. + * @param xCoords the x-coordinates array of gesture trail to be drawn. + * @param yCoords the y-coordinates array of gesture trail to be drawn. + * @param types the point types array of gesture trail. This is valid only when + * {@link GestureTrailDrawingPoints#DEBUG_SHOW_POINTS} is true. + */ + public void appendPreviewStroke(final ResizableIntArray eventTimes, + final ResizableIntArray xCoords, final ResizableIntArray yCoords, + final ResizableIntArray types) { + final int length = mPreviewEventTimes.getLength() - mLastPreviewSize; + if (length <= 0) { + return; + } + eventTimes.append(mPreviewEventTimes, mLastPreviewSize, length); + xCoords.append(mPreviewXCoordinates, mLastPreviewSize, length); + yCoords.append(mPreviewYCoordinates, mLastPreviewSize, length); + if (GestureTrailDrawingPoints.DEBUG_SHOW_POINTS) { + types.fill(GestureTrailDrawingPoints.POINT_TYPE_SAMPLED, types.getLength(), length); + } + mLastPreviewSize = mPreviewEventTimes.getLength(); + } + + /** + * Calculate interpolated points between the last interpolated point and the end of the trail. + * And return the start index of the last interpolated segment of input arrays because it + * may need to recalculate the interpolated points in the segment if further segments are + * added to this stroke. + * + * @param lastInterpolatedIndex the start index of the last interpolated segment of + * <code>eventTimes</code>, <code>xCoords</code>, and <code>yCoords</code>. + * @param eventTimes the event time array of gesture trail to be drawn. + * @param xCoords the x-coordinates array of gesture trail to be drawn. + * @param yCoords the y-coordinates array of gesture trail to be drawn. + * @param types the point types array of gesture trail. This is valid only when + * {@link GestureTrailDrawingPoints#DEBUG_SHOW_POINTS} is true. + * @return the start index of the last interpolated segment of input arrays. + */ + public int interpolateStrokeAndReturnStartIndexOfLastSegment(final int lastInterpolatedIndex, + final ResizableIntArray eventTimes, final ResizableIntArray xCoords, + final ResizableIntArray yCoords, final ResizableIntArray types) { + final int size = mPreviewEventTimes.getLength(); + final int[] pt = mPreviewEventTimes.getPrimitiveArray(); + final int[] px = mPreviewXCoordinates.getPrimitiveArray(); + final int[] py = mPreviewYCoordinates.getPrimitiveArray(); + mInterpolator.reset(px, py, 0, size); + // The last segment of gesture stroke needs to be interpolated again because the slope of + // the tangent at the last point isn't determined. + int lastInterpolatedDrawIndex = lastInterpolatedIndex; + int d1 = lastInterpolatedIndex; + for (int p2 = mLastInterpolatedPreviewIndex + 1; p2 < size; p2++) { + final int p1 = p2 - 1; + final int p0 = p1 - 1; + final int p3 = p2 + 1; + mLastInterpolatedPreviewIndex = p1; + lastInterpolatedDrawIndex = d1; + mInterpolator.setInterval(p0, p1, p2, p3); + final double m1 = Math.atan2(mInterpolator.mSlope1Y, mInterpolator.mSlope1X); + final double m2 = Math.atan2(mInterpolator.mSlope2Y, mInterpolator.mSlope2X); + final double deltaAngle = Math.abs(angularDiff(m2, m1)); + final int segmentsByAngle = (int)Math.ceil( + deltaAngle / mDrawingParams.mMaxInterpolationAngularThreshold); + final double deltaDistance = Math.hypot(mInterpolator.mP1X - mInterpolator.mP2X, + mInterpolator.mP1Y - mInterpolator.mP2Y); + final int segmentsByDistance = (int)Math.ceil(deltaDistance + / mDrawingParams.mMaxInterpolationDistanceThreshold); + final int segments = Math.min(mDrawingParams.mMaxInterpolationSegments, + Math.max(segmentsByAngle, segmentsByDistance)); + final int t1 = eventTimes.get(d1); + final int dt = pt[p2] - pt[p1]; + d1++; + for (int i = 1; i < segments; i++) { + final float t = i / (float)segments; + mInterpolator.interpolate(t); + eventTimes.addAt(d1, (int)(dt * t) + t1); + xCoords.addAt(d1, (int)mInterpolator.mInterpolatedX); + yCoords.addAt(d1, (int)mInterpolator.mInterpolatedY); + if (GestureTrailDrawingPoints.DEBUG_SHOW_POINTS) { + types.addAt(d1, GestureTrailDrawingPoints.POINT_TYPE_INTERPOLATED); + } + d1++; + } + eventTimes.addAt(d1, pt[p2]); + xCoords.addAt(d1, px[p2]); + yCoords.addAt(d1, py[p2]); + if (GestureTrailDrawingPoints.DEBUG_SHOW_POINTS) { + types.addAt(d1, GestureTrailDrawingPoints.POINT_TYPE_SAMPLED); + } + } + return lastInterpolatedDrawIndex; + } + + private static final double TWO_PI = Math.PI * 2.0d; + + /** + * Calculate the angular of rotation from <code>a0</code> to <code>a1</code>. + * + * @param a1 the angular to which the rotation ends. + * @param a0 the angular from which the rotation starts. + * @return the angular rotation value from a0 to a1, normalized to [-PI, +PI]. + */ + private static double angularDiff(final double a1, final double a0) { + double deltaAngle = a1 - a0; + while (deltaAngle > Math.PI) { + deltaAngle -= TWO_PI; + } + while (deltaAngle < -Math.PI) { + deltaAngle += TWO_PI; + } + return deltaAngle; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionParams.java new file mode 100644 index 000000000..8fdab5fb6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionParams.java @@ -0,0 +1,109 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +/** + * This class holds parameters to control how a gesture stroke is sampled and recognized. + * This class also has parameters to distinguish gesture input events from fast typing events. + * + * @attr ref android.R.styleable#MainKeyboardView_gestureStaticTimeThresholdAfterFastTyping + * @attr ref android.R.styleable#MainKeyboardView_gestureDetectFastMoveSpeedThreshold + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicThresholdDecayDuration + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicTimeThresholdFrom + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicTimeThresholdTo + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicDistanceThresholdFrom + * @attr ref android.R.styleable#MainKeyboardView_gestureDynamicDistanceThresholdTo + * @attr ref android.R.styleable#MainKeyboardView_gestureSamplingMinimumDistance + * @attr ref android.R.styleable#MainKeyboardView_gestureRecognitionMinimumTime + * @attr ref android.R.styleable#MainKeyboardView_gestureRecognitionSpeedThreshold + */ +public final class GestureStrokeRecognitionParams { + // Static threshold for gesture after fast typing + public final int mStaticTimeThresholdAfterFastTyping; // msec + // Static threshold for starting gesture detection + public final float mDetectFastMoveSpeedThreshold; // keyWidth/sec + // Dynamic threshold for gesture after fast typing + public final int mDynamicThresholdDecayDuration; // msec + // Time based threshold values + public final int mDynamicTimeThresholdFrom; // msec + public final int mDynamicTimeThresholdTo; // msec + // Distance based threshold values + public final float mDynamicDistanceThresholdFrom; // keyWidth + public final float mDynamicDistanceThresholdTo; // keyWidth + // Parameters for gesture sampling + public final float mSamplingMinimumDistance; // keyWidth + // Parameters for gesture recognition + public final int mRecognitionMinimumTime; // msec + public final float mRecognitionSpeedThreshold; // keyWidth/sec + + // Default GestureStrokeRecognitionPoints parameters. + public static final GestureStrokeRecognitionParams DEFAULT = + new GestureStrokeRecognitionParams(); + + private GestureStrokeRecognitionParams() { + // These parameter values are default and intended for testing. + mStaticTimeThresholdAfterFastTyping = 350; // msec + mDetectFastMoveSpeedThreshold = 1.5f; // keyWidth/sec + mDynamicThresholdDecayDuration = 450; // msec + mDynamicTimeThresholdFrom = 300; // msec + mDynamicTimeThresholdTo = 20; // msec + mDynamicDistanceThresholdFrom = 6.0f; // keyWidth + mDynamicDistanceThresholdTo = 0.35f; // keyWidth + // The following parameters' change will affect the result of regression test. + mSamplingMinimumDistance = 1.0f / 6.0f; // keyWidth + mRecognitionMinimumTime = 100; // msec + mRecognitionSpeedThreshold = 5.5f; // keyWidth/sec + } + + public GestureStrokeRecognitionParams(final TypedArray mainKeyboardViewAttr) { + mStaticTimeThresholdAfterFastTyping = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureStaticTimeThresholdAfterFastTyping, + DEFAULT.mStaticTimeThresholdAfterFastTyping); + mDetectFastMoveSpeedThreshold = ResourceUtils.getFraction(mainKeyboardViewAttr, + R.styleable.MainKeyboardView_gestureDetectFastMoveSpeedThreshold, + DEFAULT.mDetectFastMoveSpeedThreshold); + mDynamicThresholdDecayDuration = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureDynamicThresholdDecayDuration, + DEFAULT.mDynamicThresholdDecayDuration); + mDynamicTimeThresholdFrom = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureDynamicTimeThresholdFrom, + DEFAULT.mDynamicTimeThresholdFrom); + mDynamicTimeThresholdTo = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureDynamicTimeThresholdTo, + DEFAULT.mDynamicTimeThresholdTo); + mDynamicDistanceThresholdFrom = ResourceUtils.getFraction(mainKeyboardViewAttr, + R.styleable.MainKeyboardView_gestureDynamicDistanceThresholdFrom, + DEFAULT.mDynamicDistanceThresholdFrom); + mDynamicDistanceThresholdTo = ResourceUtils.getFraction(mainKeyboardViewAttr, + R.styleable.MainKeyboardView_gestureDynamicDistanceThresholdTo, + DEFAULT.mDynamicDistanceThresholdTo); + mSamplingMinimumDistance = ResourceUtils.getFraction(mainKeyboardViewAttr, + R.styleable.MainKeyboardView_gestureSamplingMinimumDistance, + DEFAULT.mSamplingMinimumDistance); + mRecognitionMinimumTime = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureRecognitionMinimumTime, + DEFAULT.mRecognitionMinimumTime); + mRecognitionSpeedThreshold = ResourceUtils.getFraction(mainKeyboardViewAttr, + R.styleable.MainKeyboardView_gestureRecognitionSpeedThreshold, + DEFAULT.mRecognitionSpeedThreshold); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionPoints.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionPoints.java new file mode 100644 index 000000000..b300d9c75 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionPoints.java @@ -0,0 +1,334 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.common.ResizableIntArray; + +/** + * This class holds event points to recognize a gesture stroke. + * TODO: Should be package private class. + */ +public final class GestureStrokeRecognitionPoints { + private static final String TAG = GestureStrokeRecognitionPoints.class.getSimpleName(); + private static final boolean DEBUG = false; + private static final boolean DEBUG_SPEED = false; + + // The height of extra area above the keyboard to draw gesture trails. + // Proportional to the keyboard height. + public static final float EXTRA_GESTURE_TRAIL_AREA_ABOVE_KEYBOARD_RATIO = 0.25f; + + private final int mPointerId; + private final ResizableIntArray mEventTimes = new ResizableIntArray( + Constants.DEFAULT_GESTURE_POINTS_CAPACITY); + private final ResizableIntArray mXCoordinates = new ResizableIntArray( + Constants.DEFAULT_GESTURE_POINTS_CAPACITY); + private final ResizableIntArray mYCoordinates = new ResizableIntArray( + Constants.DEFAULT_GESTURE_POINTS_CAPACITY); + + private final GestureStrokeRecognitionParams mRecognitionParams; + + private int mKeyWidth; // pixel + private int mMinYCoordinate; // pixel + private int mMaxYCoordinate; // pixel + // Static threshold for starting gesture detection + private int mDetectFastMoveSpeedThreshold; // pixel /sec + private int mDetectFastMoveTime; + private int mDetectFastMoveX; + private int mDetectFastMoveY; + // Dynamic threshold for gesture after fast typing + private boolean mAfterFastTyping; + private int mGestureDynamicDistanceThresholdFrom; // pixel + private int mGestureDynamicDistanceThresholdTo; // pixel + // Variables for gesture sampling + private int mGestureSamplingMinimumDistance; // pixel + private long mLastMajorEventTime; + private int mLastMajorEventX; + private int mLastMajorEventY; + // Variables for gesture recognition + private int mGestureRecognitionSpeedThreshold; // pixel / sec + private int mIncrementalRecognitionSize; + private int mLastIncrementalBatchSize; + + private static final int MSEC_PER_SEC = 1000; + + // TODO: Make this package private + public GestureStrokeRecognitionPoints(final int pointerId, + final GestureStrokeRecognitionParams recognitionParams) { + mPointerId = pointerId; + mRecognitionParams = recognitionParams; + } + + // TODO: Make this package private + public void setKeyboardGeometry(final int keyWidth, final int keyboardHeight) { + mKeyWidth = keyWidth; + mMinYCoordinate = -(int)(keyboardHeight * EXTRA_GESTURE_TRAIL_AREA_ABOVE_KEYBOARD_RATIO); + mMaxYCoordinate = keyboardHeight; + // TODO: Find an appropriate base metric for these length. Maybe diagonal length of the key? + mDetectFastMoveSpeedThreshold = (int)( + keyWidth * mRecognitionParams.mDetectFastMoveSpeedThreshold); + mGestureDynamicDistanceThresholdFrom = (int)( + keyWidth * mRecognitionParams.mDynamicDistanceThresholdFrom); + mGestureDynamicDistanceThresholdTo = (int)( + keyWidth * mRecognitionParams.mDynamicDistanceThresholdTo); + mGestureSamplingMinimumDistance = (int)( + keyWidth * mRecognitionParams.mSamplingMinimumDistance); + mGestureRecognitionSpeedThreshold = (int)( + keyWidth * mRecognitionParams.mRecognitionSpeedThreshold); + if (DEBUG) { + Log.d(TAG, String.format( + "[%d] setKeyboardGeometry: keyWidth=%3d tT=%3d >> %3d tD=%3d >> %3d", + mPointerId, keyWidth, + mRecognitionParams.mDynamicTimeThresholdFrom, + mRecognitionParams.mDynamicTimeThresholdTo, + mGestureDynamicDistanceThresholdFrom, + mGestureDynamicDistanceThresholdTo)); + } + } + + // TODO: Make this package private + public int getLength() { + return mEventTimes.getLength(); + } + + // TODO: Make this package private + public void addDownEventPoint(final int x, final int y, final int elapsedTimeSinceFirstDown, + final int elapsedTimeSinceLastTyping) { + reset(); + if (elapsedTimeSinceLastTyping < mRecognitionParams.mStaticTimeThresholdAfterFastTyping) { + mAfterFastTyping = true; + } + if (DEBUG) { + Log.d(TAG, String.format("[%d] onDownEvent: dT=%3d%s", mPointerId, + elapsedTimeSinceLastTyping, mAfterFastTyping ? " afterFastTyping" : "")); + } + // Call {@link #addEventPoint(int,int,int,boolean)} to record this down event point as a + // major event point. + addEventPoint(x, y, elapsedTimeSinceFirstDown, true /* isMajorEvent */); + } + + private int getGestureDynamicDistanceThreshold(final int deltaTime) { + if (!mAfterFastTyping || deltaTime >= mRecognitionParams.mDynamicThresholdDecayDuration) { + return mGestureDynamicDistanceThresholdTo; + } + final int decayedThreshold = + (mGestureDynamicDistanceThresholdFrom - mGestureDynamicDistanceThresholdTo) + * deltaTime / mRecognitionParams.mDynamicThresholdDecayDuration; + return mGestureDynamicDistanceThresholdFrom - decayedThreshold; + } + + private int getGestureDynamicTimeThreshold(final int deltaTime) { + if (!mAfterFastTyping || deltaTime >= mRecognitionParams.mDynamicThresholdDecayDuration) { + return mRecognitionParams.mDynamicTimeThresholdTo; + } + final int decayedThreshold = + (mRecognitionParams.mDynamicTimeThresholdFrom + - mRecognitionParams.mDynamicTimeThresholdTo) + * deltaTime / mRecognitionParams.mDynamicThresholdDecayDuration; + return mRecognitionParams.mDynamicTimeThresholdFrom - decayedThreshold; + } + + // TODO: Make this package private + public final boolean isStartOfAGesture() { + if (!hasDetectedFastMove()) { + return false; + } + final int size = getLength(); + if (size <= 0) { + return false; + } + final int lastIndex = size - 1; + final int deltaTime = mEventTimes.get(lastIndex) - mDetectFastMoveTime; + if (deltaTime < 0) { + return false; + } + final int deltaDistance = getDistance( + mXCoordinates.get(lastIndex), mYCoordinates.get(lastIndex), + mDetectFastMoveX, mDetectFastMoveY); + final int distanceThreshold = getGestureDynamicDistanceThreshold(deltaTime); + final int timeThreshold = getGestureDynamicTimeThreshold(deltaTime); + final boolean isStartOfAGesture = deltaTime >= timeThreshold + && deltaDistance >= distanceThreshold; + if (DEBUG) { + Log.d(TAG, String.format("[%d] isStartOfAGesture: dT=%3d tT=%3d dD=%3d tD=%3d%s%s", + mPointerId, deltaTime, timeThreshold, + deltaDistance, distanceThreshold, + mAfterFastTyping ? " afterFastTyping" : "", + isStartOfAGesture ? " startOfAGesture" : "")); + } + return isStartOfAGesture; + } + + // TODO: Make this package private + public void duplicateLastPointWith(final int time) { + final int lastIndex = getLength() - 1; + if (lastIndex >= 0) { + final int x = mXCoordinates.get(lastIndex); + final int y = mYCoordinates.get(lastIndex); + if (DEBUG) { + Log.d(TAG, String.format("[%d] duplicateLastPointWith: %d,%d|%d", mPointerId, + x, y, time)); + } + // TODO: Have appendMajorPoint() + appendPoint(x, y, time); + updateIncrementalRecognitionSize(x, y, time); + } + } + + private void reset() { + mIncrementalRecognitionSize = 0; + mLastIncrementalBatchSize = 0; + mEventTimes.setLength(0); + mXCoordinates.setLength(0); + mYCoordinates.setLength(0); + mLastMajorEventTime = 0; + mDetectFastMoveTime = 0; + mAfterFastTyping = false; + } + + private void appendPoint(final int x, final int y, final int time) { + final int lastIndex = getLength() - 1; + // The point that is created by {@link duplicateLastPointWith(int)} may have later event + // time than the next {@link MotionEvent}. To maintain the monotonicity of the event time, + // drop the successive point here. + if (lastIndex >= 0 && mEventTimes.get(lastIndex) > time) { + Log.w(TAG, String.format("[%d] drop stale event: %d,%d|%d last: %d,%d|%d", mPointerId, + x, y, time, mXCoordinates.get(lastIndex), mYCoordinates.get(lastIndex), + mEventTimes.get(lastIndex))); + return; + } + mEventTimes.add(time); + mXCoordinates.add(x); + mYCoordinates.add(y); + } + + private void updateMajorEvent(final int x, final int y, final int time) { + mLastMajorEventTime = time; + mLastMajorEventX = x; + mLastMajorEventY = y; + } + + private final boolean hasDetectedFastMove() { + return mDetectFastMoveTime > 0; + } + + private int detectFastMove(final int x, final int y, final int time) { + final int size = getLength(); + final int lastIndex = size - 1; + final int lastX = mXCoordinates.get(lastIndex); + final int lastY = mYCoordinates.get(lastIndex); + final int dist = getDistance(lastX, lastY, x, y); + final int msecs = time - mEventTimes.get(lastIndex); + if (msecs > 0) { + final int pixels = getDistance(lastX, lastY, x, y); + final int pixelsPerSec = pixels * MSEC_PER_SEC; + if (DEBUG_SPEED) { + final float speed = (float)pixelsPerSec / msecs / mKeyWidth; + Log.d(TAG, String.format("[%d] detectFastMove: speed=%5.2f", mPointerId, speed)); + } + // Equivalent to (pixels / msecs < mStartSpeedThreshold / MSEC_PER_SEC) + if (!hasDetectedFastMove() && pixelsPerSec > mDetectFastMoveSpeedThreshold * msecs) { + if (DEBUG) { + final float speed = (float)pixelsPerSec / msecs / mKeyWidth; + Log.d(TAG, String.format( + "[%d] detectFastMove: speed=%5.2f T=%3d points=%3d fastMove", + mPointerId, speed, time, size)); + } + mDetectFastMoveTime = time; + mDetectFastMoveX = x; + mDetectFastMoveY = y; + } + } + return dist; + } + + /** + * Add an event point to this gesture stroke recognition points. Returns true if the event + * point is on the valid gesture area. + * @param x the x-coordinate of the event point + * @param y the y-coordinate of the event point + * @param time the elapsed time in millisecond from the first gesture down + * @param isMajorEvent false if this is a historical move event + * @return true if the event point is on the valid gesture area + */ + // TODO: Make this package private + public boolean addEventPoint(final int x, final int y, final int time, + final boolean isMajorEvent) { + final int size = getLength(); + if (size <= 0) { + // The first event of this stroke (a.k.a. down event). + appendPoint(x, y, time); + updateMajorEvent(x, y, time); + } else { + final int distance = detectFastMove(x, y, time); + if (distance > mGestureSamplingMinimumDistance) { + appendPoint(x, y, time); + } + } + if (isMajorEvent) { + updateIncrementalRecognitionSize(x, y, time); + updateMajorEvent(x, y, time); + } + return y >= mMinYCoordinate && y < mMaxYCoordinate; + } + + private void updateIncrementalRecognitionSize(final int x, final int y, final int time) { + final int msecs = (int)(time - mLastMajorEventTime); + if (msecs <= 0) { + return; + } + final int pixels = getDistance(mLastMajorEventX, mLastMajorEventY, x, y); + final int pixelsPerSec = pixels * MSEC_PER_SEC; + // Equivalent to (pixels / msecs < mGestureRecognitionThreshold / MSEC_PER_SEC) + if (pixelsPerSec < mGestureRecognitionSpeedThreshold * msecs) { + mIncrementalRecognitionSize = getLength(); + } + } + + // TODO: Make this package private + public final boolean hasRecognitionTimePast( + final long currentTime, final long lastRecognitionTime) { + return currentTime > lastRecognitionTime + mRecognitionParams.mRecognitionMinimumTime; + } + + // TODO: Make this package private + public final void appendAllBatchPoints(final InputPointers out) { + appendBatchPoints(out, getLength()); + } + + // TODO: Make this package private + public final void appendIncrementalBatchPoints(final InputPointers out) { + appendBatchPoints(out, mIncrementalRecognitionSize); + } + + private void appendBatchPoints(final InputPointers out, final int size) { + final int length = size - mLastIncrementalBatchSize; + if (length <= 0) { + return; + } + out.append(mPointerId, mEventTimes, mXCoordinates, mYCoordinates, + mLastIncrementalBatchSize, length); + mLastIncrementalBatchSize = size; + } + + private static int getDistance(final int x1, final int y1, final int x2, final int y2) { + return (int)Math.hypot(x1 - x2, y1 - y2); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingParams.java new file mode 100644 index 000000000..cbba54fe2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingParams.java @@ -0,0 +1,79 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; + +import org.kelar.inputmethod.latin.R; + +/** + * This class holds parameters to control how a gesture trail is drawn and animated on the screen. + * + * On the other hand, {@link GestureStrokeDrawingParams} class controls how each gesture stroke is + * sampled and interpolated. This class controls how those gesture strokes are displayed as a + * gesture trail and animated on the screen. + * + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailFadeoutStartDelay + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailFadeoutDuration + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailUpdateInterval + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailColor + * @attr ref android.R.styleable#MainKeyboardView_gestureTrailWidth + */ +final class GestureTrailDrawingParams { + private static final int FADEOUT_START_DELAY_FOR_DEBUG = 2000; // millisecond + private static final int FADEOUT_DURATION_FOR_DEBUG = 200; // millisecond + + public final int mTrailColor; + public final float mTrailStartWidth; + public final float mTrailEndWidth; + public final float mTrailBodyRatio; + public boolean mTrailShadowEnabled; + public final float mTrailShadowRatio; + public final int mFadeoutStartDelay; + public final int mFadeoutDuration; + public final int mUpdateInterval; + + public final int mTrailLingerDuration; + + public GestureTrailDrawingParams(final TypedArray mainKeyboardViewAttr) { + mTrailColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_gestureTrailColor, 0); + mTrailStartWidth = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureTrailStartWidth, 0.0f); + mTrailEndWidth = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_gestureTrailEndWidth, 0.0f); + final int PERCENTAGE_INT = 100; + mTrailBodyRatio = (float)mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureTrailBodyRatio, PERCENTAGE_INT) + / (float)PERCENTAGE_INT; + final int trailShadowRatioInt = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureTrailShadowRatio, 0); + mTrailShadowEnabled = (trailShadowRatioInt > 0); + mTrailShadowRatio = (float)trailShadowRatioInt / (float)PERCENTAGE_INT; + mFadeoutStartDelay = GestureTrailDrawingPoints.DEBUG_SHOW_POINTS + ? FADEOUT_START_DELAY_FOR_DEBUG + : mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureTrailFadeoutStartDelay, 0); + mFadeoutDuration = GestureTrailDrawingPoints.DEBUG_SHOW_POINTS + ? FADEOUT_DURATION_FOR_DEBUG + : mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureTrailFadeoutDuration, 0); + mTrailLingerDuration = mFadeoutStartDelay + mFadeoutDuration; + mUpdateInterval = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_gestureTrailUpdateInterval, 0); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingPoints.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingPoints.java new file mode 100644 index 000000000..2dc43dc91 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingPoints.java @@ -0,0 +1,276 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.os.SystemClock; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.ResizableIntArray; + +/** + * This class holds drawing points to represent a gesture trail. The gesture trail may contain + * multiple non-contiguous gesture strokes and will be animated asynchronously from gesture input. + * + * On the other hand, {@link GestureStrokeDrawingPoints} class holds drawing points of each gesture + * stroke. This class holds drawing points of those gesture strokes to draw as a gesture trail. + * Drawing points in this class will be asynchronously removed when fading out animation goes. + */ +final class GestureTrailDrawingPoints { + public static final boolean DEBUG_SHOW_POINTS = false; + public static final int POINT_TYPE_SAMPLED = 1; + public static final int POINT_TYPE_INTERPOLATED = 2; + + private static final int DEFAULT_CAPACITY = GestureStrokeDrawingPoints.PREVIEW_CAPACITY; + + // These three {@link ResizableIntArray}s should be synchronized by {@link #mEventTimes}. + private final ResizableIntArray mXCoordinates = new ResizableIntArray(DEFAULT_CAPACITY); + private final ResizableIntArray mYCoordinates = new ResizableIntArray(DEFAULT_CAPACITY); + private final ResizableIntArray mEventTimes = new ResizableIntArray(DEFAULT_CAPACITY); + private final ResizableIntArray mPointTypes = new ResizableIntArray( + DEBUG_SHOW_POINTS ? DEFAULT_CAPACITY : 0); + private int mCurrentStrokeId = -1; + // The wall time of the zero value in {@link #mEventTimes} + private long mCurrentTimeBase; + private int mTrailStartIndex; + private int mLastInterpolatedDrawIndex; + + // Use this value as imaginary zero because x-coordinates may be zero. + private static final int DOWN_EVENT_MARKER = -128; + + private static int markAsDownEvent(final int xCoord) { + return DOWN_EVENT_MARKER - xCoord; + } + + private static boolean isDownEventXCoord(final int xCoordOrMark) { + return xCoordOrMark <= DOWN_EVENT_MARKER; + } + + private static int getXCoordValue(final int xCoordOrMark) { + return isDownEventXCoord(xCoordOrMark) + ? DOWN_EVENT_MARKER - xCoordOrMark : xCoordOrMark; + } + + public void addStroke(final GestureStrokeDrawingPoints stroke, final long downTime) { + synchronized (mEventTimes) { + addStrokeLocked(stroke, downTime); + } + } + + private void addStrokeLocked(final GestureStrokeDrawingPoints stroke, final long downTime) { + final int trailSize = mEventTimes.getLength(); + stroke.appendPreviewStroke(mEventTimes, mXCoordinates, mYCoordinates, mPointTypes); + if (mEventTimes.getLength() == trailSize) { + return; + } + final int[] eventTimes = mEventTimes.getPrimitiveArray(); + final int strokeId = stroke.getGestureStrokeId(); + // Because interpolation algorithm in {@link GestureStrokeDrawingPoints} can't determine + // the interpolated points in the last segment of gesture stroke, it may need recalculation + // of interpolation when new segments are added to the stroke. + // {@link #mLastInterpolatedDrawIndex} holds the start index of the last segment. It may + // be updated by the interpolation + // {@link GestureStrokeDrawingPoints#interpolatePreviewStroke} + // or by animation {@link #drawGestureTrail(Canvas,Paint,Rect,GestureTrailDrawingParams)} + // below. + final int lastInterpolatedIndex = (strokeId == mCurrentStrokeId) + ? mLastInterpolatedDrawIndex : trailSize; + mLastInterpolatedDrawIndex = stroke.interpolateStrokeAndReturnStartIndexOfLastSegment( + lastInterpolatedIndex, mEventTimes, mXCoordinates, mYCoordinates, mPointTypes); + if (strokeId != mCurrentStrokeId) { + final int elapsedTime = (int)(downTime - mCurrentTimeBase); + for (int i = mTrailStartIndex; i < trailSize; i++) { + // Decay the previous strokes' event times. + eventTimes[i] -= elapsedTime; + } + final int[] xCoords = mXCoordinates.getPrimitiveArray(); + final int downIndex = trailSize; + xCoords[downIndex] = markAsDownEvent(xCoords[downIndex]); + mCurrentTimeBase = downTime - eventTimes[downIndex]; + mCurrentStrokeId = strokeId; + } + } + + /** + * Calculate the alpha of a gesture trail. + * A gesture trail starts from fully opaque. After mFadeStartDelay has been passed, the alpha + * of a trail reduces in proportion to the elapsed time. Then after mFadeDuration has been + * passed, a trail becomes fully transparent. + * + * @param elapsedTime the elapsed time since a trail has been made. + * @param params gesture trail display parameters + * @return the width of a gesture trail + */ + private static int getAlpha(final int elapsedTime, final GestureTrailDrawingParams params) { + if (elapsedTime < params.mFadeoutStartDelay) { + return Constants.Color.ALPHA_OPAQUE; + } + final int decreasingAlpha = Constants.Color.ALPHA_OPAQUE + * (elapsedTime - params.mFadeoutStartDelay) + / params.mFadeoutDuration; + return Constants.Color.ALPHA_OPAQUE - decreasingAlpha; + } + + /** + * Calculate the width of a gesture trail. + * A gesture trail starts from the width of mTrailStartWidth and reduces its width in proportion + * to the elapsed time. After mTrailEndWidth has been passed, the width becomes mTraiLEndWidth. + * + * @param elapsedTime the elapsed time since a trail has been made. + * @param params gesture trail display parameters + * @return the width of a gesture trail + */ + private static float getWidth(final int elapsedTime, final GestureTrailDrawingParams params) { + final float deltaWidth = params.mTrailStartWidth - params.mTrailEndWidth; + return params.mTrailStartWidth - (deltaWidth * elapsedTime) / params.mTrailLingerDuration; + } + + private final RoundedLine mRoundedLine = new RoundedLine(); + private final Rect mRoundedLineBounds = new Rect(); + + /** + * Draw gesture trail + * @param canvas The canvas to draw the gesture trail + * @param paint The paint object to be used to draw the gesture trail + * @param outBoundsRect the bounding box of this gesture trail drawing + * @param params The drawing parameters of gesture trail + * @return true if some gesture trails remain to be drawn + */ + public boolean drawGestureTrail(final Canvas canvas, final Paint paint, + final Rect outBoundsRect, final GestureTrailDrawingParams params) { + synchronized (mEventTimes) { + return drawGestureTrailLocked(canvas, paint, outBoundsRect, params); + } + } + + private boolean drawGestureTrailLocked(final Canvas canvas, final Paint paint, + final Rect outBoundsRect, final GestureTrailDrawingParams params) { + // Initialize bounds rectangle. + outBoundsRect.setEmpty(); + final int trailSize = mEventTimes.getLength(); + if (trailSize == 0) { + return false; + } + + final int[] eventTimes = mEventTimes.getPrimitiveArray(); + final int[] xCoords = mXCoordinates.getPrimitiveArray(); + final int[] yCoords = mYCoordinates.getPrimitiveArray(); + final int[] pointTypes = mPointTypes.getPrimitiveArray(); + final int sinceDown = (int)(SystemClock.uptimeMillis() - mCurrentTimeBase); + int startIndex; + for (startIndex = mTrailStartIndex; startIndex < trailSize; startIndex++) { + final int elapsedTime = sinceDown - eventTimes[startIndex]; + // Skip too old trail points. + if (elapsedTime < params.mTrailLingerDuration) { + break; + } + } + mTrailStartIndex = startIndex; + + if (startIndex < trailSize) { + paint.setColor(params.mTrailColor); + paint.setStyle(Paint.Style.FILL); + final RoundedLine roundedLine = mRoundedLine; + int p1x = getXCoordValue(xCoords[startIndex]); + int p1y = yCoords[startIndex]; + final int lastTime = sinceDown - eventTimes[startIndex]; + float r1 = getWidth(lastTime, params) / 2.0f; + for (int i = startIndex + 1; i < trailSize; i++) { + final int elapsedTime = sinceDown - eventTimes[i]; + final int p2x = getXCoordValue(xCoords[i]); + final int p2y = yCoords[i]; + final float r2 = getWidth(elapsedTime, params) / 2.0f; + // Draw trail line only when the current point isn't a down point. + if (!isDownEventXCoord(xCoords[i])) { + final float body1 = r1 * params.mTrailBodyRatio; + final float body2 = r2 * params.mTrailBodyRatio; + final Path path = roundedLine.makePath(p1x, p1y, body1, p2x, p2y, body2); + if (!path.isEmpty()) { + roundedLine.getBounds(mRoundedLineBounds); + if (params.mTrailShadowEnabled) { + final float shadow2 = r2 * params.mTrailShadowRatio; + paint.setShadowLayer(shadow2, 0.0f, 0.0f, params.mTrailColor); + final int shadowInset = -(int)Math.ceil(shadow2); + mRoundedLineBounds.inset(shadowInset, shadowInset); + } + // Take union for the bounds. + outBoundsRect.union(mRoundedLineBounds); + final int alpha = getAlpha(elapsedTime, params); + paint.setAlpha(alpha); + canvas.drawPath(path, paint); + } + } + p1x = p2x; + p1y = p2y; + r1 = r2; + } + if (DEBUG_SHOW_POINTS) { + debugDrawPoints(canvas, startIndex, trailSize, paint); + } + } + + final int newSize = trailSize - startIndex; + if (newSize < startIndex) { + mTrailStartIndex = 0; + if (newSize > 0) { + System.arraycopy(eventTimes, startIndex, eventTimes, 0, newSize); + System.arraycopy(xCoords, startIndex, xCoords, 0, newSize); + System.arraycopy(yCoords, startIndex, yCoords, 0, newSize); + if (DEBUG_SHOW_POINTS) { + System.arraycopy(pointTypes, startIndex, pointTypes, 0, newSize); + } + } + mEventTimes.setLength(newSize); + mXCoordinates.setLength(newSize); + mYCoordinates.setLength(newSize); + if (DEBUG_SHOW_POINTS) { + mPointTypes.setLength(newSize); + } + // The start index of the last segment of the stroke + // {@link mLastInterpolatedDrawIndex} should also be updated because all array + // elements have just been shifted for compaction or been zeroed. + mLastInterpolatedDrawIndex = Math.max(mLastInterpolatedDrawIndex - startIndex, 0); + } + return newSize > 0; + } + + private void debugDrawPoints(final Canvas canvas, final int startIndex, final int endIndex, + final Paint paint) { + final int[] xCoords = mXCoordinates.getPrimitiveArray(); + final int[] yCoords = mYCoordinates.getPrimitiveArray(); + final int[] pointTypes = mPointTypes.getPrimitiveArray(); + // {@link Paint} that is zero width stroke and anti alias off draws exactly 1 pixel. + paint.setAntiAlias(false); + paint.setStrokeWidth(0); + for (int i = startIndex; i < endIndex; i++) { + final int pointType = pointTypes[i]; + if (pointType == POINT_TYPE_INTERPOLATED) { + paint.setColor(Color.RED); + } else if (pointType == POINT_TYPE_SAMPLED) { + paint.setColor(0xFFA000FF); + } else { + paint.setColor(Color.GREEN); + } + canvas.drawPoint(getXCoordValue(xCoords[i]), yCoords[i], paint); + } + paint.setAntiAlias(true); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailsDrawingPreview.java b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailsDrawingPreview.java new file mode 100644 index 000000000..de0cf1221 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailsDrawingPreview.java @@ -0,0 +1,174 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.os.Handler; +import android.util.SparseArray; + +import org.kelar.inputmethod.keyboard.PointerTracker; + +/** + * Draw preview graphics of multiple gesture trails during gesture input. + */ +public final class GestureTrailsDrawingPreview extends AbstractDrawingPreview implements Runnable { + private final SparseArray<GestureTrailDrawingPoints> mGestureTrails = new SparseArray<>(); + private final GestureTrailDrawingParams mDrawingParams; + private final Paint mGesturePaint; + private int mOffscreenWidth; + private int mOffscreenHeight; + private int mOffscreenOffsetY; + private Bitmap mOffscreenBuffer; + private final Canvas mOffscreenCanvas = new Canvas(); + private final Rect mOffscreenSrcRect = new Rect(); + private final Rect mDirtyRect = new Rect(); + private final Rect mGestureTrailBoundsRect = new Rect(); // per trail + + private final Handler mDrawingHandler = new Handler(); + + public GestureTrailsDrawingPreview(final TypedArray mainKeyboardViewAttr) { + mDrawingParams = new GestureTrailDrawingParams(mainKeyboardViewAttr); + final Paint gesturePaint = new Paint(); + gesturePaint.setAntiAlias(true); + gesturePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC)); + mGesturePaint = gesturePaint; + } + + @Override + public void setKeyboardViewGeometry(final int[] originCoords, final int width, + final int height) { + super.setKeyboardViewGeometry(originCoords, width, height); + mOffscreenOffsetY = (int)(height + * GestureStrokeRecognitionPoints.EXTRA_GESTURE_TRAIL_AREA_ABOVE_KEYBOARD_RATIO); + mOffscreenWidth = width; + mOffscreenHeight = mOffscreenOffsetY + height; + } + + @Override + public void onDeallocateMemory() { + freeOffscreenBuffer(); + } + + private void freeOffscreenBuffer() { + mOffscreenCanvas.setBitmap(null); + mOffscreenCanvas.setMatrix(null); + if (mOffscreenBuffer != null) { + mOffscreenBuffer.recycle(); + mOffscreenBuffer = null; + } + } + + private void mayAllocateOffscreenBuffer() { + if (mOffscreenBuffer != null && mOffscreenBuffer.getWidth() == mOffscreenWidth + && mOffscreenBuffer.getHeight() == mOffscreenHeight) { + return; + } + freeOffscreenBuffer(); + mOffscreenBuffer = Bitmap.createBitmap( + mOffscreenWidth, mOffscreenHeight, Bitmap.Config.ARGB_8888); + mOffscreenCanvas.setBitmap(mOffscreenBuffer); + mOffscreenCanvas.translate(0, mOffscreenOffsetY); + } + + private boolean drawGestureTrails(final Canvas offscreenCanvas, final Paint paint, + final Rect dirtyRect) { + // Clear previous dirty rectangle. + if (!dirtyRect.isEmpty()) { + paint.setColor(Color.TRANSPARENT); + paint.setStyle(Paint.Style.FILL); + offscreenCanvas.drawRect(dirtyRect, paint); + } + dirtyRect.setEmpty(); + boolean needsUpdatingGestureTrail = false; + // Draw gesture trails to offscreen buffer. + synchronized (mGestureTrails) { + // Trails count == fingers count that have ever been active. + final int trailsCount = mGestureTrails.size(); + for (int index = 0; index < trailsCount; index++) { + final GestureTrailDrawingPoints trail = mGestureTrails.valueAt(index); + needsUpdatingGestureTrail |= trail.drawGestureTrail(offscreenCanvas, paint, + mGestureTrailBoundsRect, mDrawingParams); + // {@link #mGestureTrailBoundsRect} has bounding box of the trail. + dirtyRect.union(mGestureTrailBoundsRect); + } + } + return needsUpdatingGestureTrail; + } + + @Override + public void run() { + // Update preview. + invalidateDrawingView(); + } + + /** + * Draws the preview + * @param canvas The canvas where the preview is drawn. + */ + @Override + public void drawPreview(final Canvas canvas) { + if (!isPreviewEnabled()) { + return; + } + mayAllocateOffscreenBuffer(); + // Draw gesture trails to offscreen buffer. + final boolean needsUpdatingGestureTrail = drawGestureTrails( + mOffscreenCanvas, mGesturePaint, mDirtyRect); + if (needsUpdatingGestureTrail) { + mDrawingHandler.removeCallbacks(this); + mDrawingHandler.postDelayed(this, mDrawingParams.mUpdateInterval); + } + // Transfer offscreen buffer to screen. + if (!mDirtyRect.isEmpty()) { + mOffscreenSrcRect.set(mDirtyRect); + mOffscreenSrcRect.offset(0, mOffscreenOffsetY); + canvas.drawBitmap(mOffscreenBuffer, mOffscreenSrcRect, mDirtyRect, null); + // Note: Defer clearing the dirty rectangle here because we will get cleared + // rectangle on the canvas. + } + } + + /** + * Set the position of the preview. + * @param tracker The new location of the preview is based on the points in PointerTracker. + */ + @Override + public void setPreviewPosition(final PointerTracker tracker) { + if (!isPreviewEnabled()) { + return; + } + GestureTrailDrawingPoints trail; + synchronized (mGestureTrails) { + trail = mGestureTrails.get(tracker.mPointerId); + if (trail == null) { + trail = new GestureTrailDrawingPoints(); + mGestureTrails.put(tracker.mPointerId, trail); + } + } + trail.addStroke(tracker.getGestureStrokeDrawingPoints(), tracker.getDownTime()); + + // TODO: Should narrow the invalidate region. + invalidateDrawingView(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/HermiteInterpolator.java b/java/src/org/kelar/inputmethod/keyboard/internal/HermiteInterpolator.java new file mode 100644 index 000000000..1c93ea32c --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/HermiteInterpolator.java @@ -0,0 +1,161 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +/** + * Interpolates XY-coordinates using Cubic Hermite Curve. + */ +public final class HermiteInterpolator { + private int[] mXCoords; + private int[] mYCoords; + private int mMinPos; + private int mMaxPos; + + // Working variable to calculate interpolated value. + /** The coordinates of the start point of the interval. */ + public int mP1X, mP1Y; + /** The coordinates of the end point of the interval. */ + public int mP2X, mP2Y; + /** The slope of the tangent at the start point. */ + public float mSlope1X, mSlope1Y; + /** The slope of the tangent at the end point. */ + public float mSlope2X, mSlope2Y; + /** The interpolated coordinates. + * The return variables of {@link #interpolate(float)} to avoid instantiations. + */ + public float mInterpolatedX, mInterpolatedY; + + public HermiteInterpolator() { + // Nothing to do with here. + } + + /** + * Reset this interpolator to point XY-coordinates data. + * @param xCoords the array of x-coordinates. Valid data are in left-open interval + * <code>[minPos, maxPos)</code>. + * @param yCoords the array of y-coordinates. Valid data are in left-open interval + * <code>[minPos, maxPos)</code>. + * @param minPos the minimum index of left-open interval of valid data. + * @param maxPos the maximum index of left-open interval of valid data. + */ + public void reset(final int[] xCoords, final int[] yCoords, final int minPos, + final int maxPos) { + mXCoords = xCoords; + mYCoords = yCoords; + mMinPos = minPos; + mMaxPos = maxPos; + } + + /** + * Set interpolation interval. + * <p> + * The start and end coordinates of the interval will be set in {@link #mP1X}, {@link #mP1Y}, + * {@link #mP2X}, and {@link #mP2Y}. The slope of the tangents at start and end points will be + * set in {@link #mSlope1X}, {@link #mSlope1Y}, {@link #mSlope2X}, and {@link #mSlope2Y}. + * + * @param p0 the index just before interpolation interval. If <code>p1</code> points the start + * of valid points, <code>p0</code> must be less than <code>minPos</code> of + * {@link #reset(int[],int[],int,int)}. + * @param p1 the start index of interpolation interval. + * @param p2 the end index of interpolation interval. + * @param p3 the index just after interpolation interval. If <code>p2</code> points the end of + * valid points, <code>p3</code> must be equal or greater than <code>maxPos</code> of + * {@link #reset(int[],int[],int,int)}. + */ + public void setInterval(final int p0, final int p1, final int p2, final int p3) { + mP1X = mXCoords[p1]; + mP1Y = mYCoords[p1]; + mP2X = mXCoords[p2]; + mP2Y = mYCoords[p2]; + // A(ax,ay) is the vector p1->p2. + final int ax = mP2X - mP1X; + final int ay = mP2Y - mP1Y; + + // Calculate the slope of the tangent at p1. + if (p0 >= mMinPos) { + // p1 has previous valid point p0. + // The slope of the tangent is half of the vector p0->p2. + mSlope1X = (mP2X - mXCoords[p0]) / 2.0f; + mSlope1Y = (mP2Y - mYCoords[p0]) / 2.0f; + } else if (p3 < mMaxPos) { + // p1 has no previous valid point, but p2 has next valid point p3. + // B(bx,by) is the slope vector of the tangent at p2. + final float bx = (mXCoords[p3] - mP1X) / 2.0f; + final float by = (mYCoords[p3] - mP1Y) / 2.0f; + final float crossProdAB = ax * by - ay * bx; + final float dotProdAB = ax * bx + ay * by; + final float normASquare = ax * ax + ay * ay; + final float invHalfNormASquare = 1.0f / normASquare / 2.0f; + // The slope of the tangent is the mirror image of vector B to vector A. + mSlope1X = invHalfNormASquare * (dotProdAB * ax + crossProdAB * ay); + mSlope1Y = invHalfNormASquare * (dotProdAB * ay - crossProdAB * ax); + } else { + // p1 and p2 have no previous valid point. (Interval has only point p1 and p2) + mSlope1X = ax; + mSlope1Y = ay; + } + + // Calculate the slope of the tangent at p2. + if (p3 < mMaxPos) { + // p2 has next valid point p3. + // The slope of the tangent is half of the vector p1->p3. + mSlope2X = (mXCoords[p3] - mP1X) / 2.0f; + mSlope2Y = (mYCoords[p3] - mP1Y) / 2.0f; + } else if (p0 >= mMinPos) { + // p2 has no next valid point, but p1 has previous valid point p0. + // B(bx,by) is the slope vector of the tangent at p1. + final float bx = (mP2X - mXCoords[p0]) / 2.0f; + final float by = (mP2Y - mYCoords[p0]) / 2.0f; + final float crossProdAB = ax * by - ay * bx; + final float dotProdAB = ax * bx + ay * by; + final float normASquare = ax * ax + ay * ay; + final float invHalfNormASquare = 1.0f / normASquare / 2.0f; + // The slope of the tangent is the mirror image of vector B to vector A. + mSlope2X = invHalfNormASquare * (dotProdAB * ax + crossProdAB * ay); + mSlope2Y = invHalfNormASquare * (dotProdAB * ay - crossProdAB * ax); + } else { + // p1 and p2 has no previous valid point. (Interval has only point p1 and p2) + mSlope2X = ax; + mSlope2Y = ay; + } + } + + /** + * Calculate interpolation value at <code>t</code> in unit interval <code>[0,1]</code>. + * <p> + * On the unit interval [0,1], given a starting point p1 at t=0 and an ending point p2 at t=1 + * with the slope of the tangent m1 at p1 and m2 at p2, the polynomial of cubic Hermite curve + * can be defined by + * p(t) = (1+2t)(1-t)(1-t)*p1 + t(1-t)(1-t)*m1 + (3-2t)t^2*p2 + (t-1)t^2*m2 + * where t is an element of [0,1]. + * <p> + * The interpolated XY-coordinates will be set in {@link #mInterpolatedX} and + * {@link #mInterpolatedY}. + * + * @param t the interpolation parameter. The value must be in close interval <code>[0,1]</code>. + */ + public void interpolate(final float t) { + final float omt = 1.0f - t; + final float tm2 = 2.0f * t; + final float k1 = 1.0f + tm2; + final float k2 = 3.0f - tm2; + final float omt2 = omt * omt; + final float t2 = t * t; + mInterpolatedX = (k1 * mP1X + t * mSlope1X) * omt2 + (k2 * mP2X - omt * mSlope2X) * t2; + mInterpolatedY = (k1 * mP1Y + t * mSlope1Y) * omt2 + (k2 * mP2Y - omt * mSlope2Y) * t2; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyDrawParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyDrawParams.java new file mode 100644 index 000000000..a4550b5fd --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyDrawParams.java @@ -0,0 +1,167 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.graphics.Typeface; + +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class KeyDrawParams { + @Nonnull + public Typeface mTypeface = Typeface.DEFAULT; + + public int mLetterSize; + public int mLabelSize; + public int mLargeLetterSize; + public int mHintLetterSize; + public int mShiftedLetterHintSize; + public int mHintLabelSize; + public int mPreviewTextSize; + + public int mTextColor; + public int mTextInactivatedColor; + public int mTextShadowColor; + public int mFunctionalTextColor; + public int mHintLetterColor; + public int mHintLabelColor; + public int mShiftedLetterHintInactivatedColor; + public int mShiftedLetterHintActivatedColor; + public int mPreviewTextColor; + + public float mHintLabelVerticalAdjustment; + public float mLabelOffCenterRatio; + public float mHintLabelOffCenterRatio; + + public int mAnimAlpha; + + public KeyDrawParams() {} + + private KeyDrawParams(@Nonnull final KeyDrawParams copyFrom) { + mTypeface = copyFrom.mTypeface; + + mLetterSize = copyFrom.mLetterSize; + mLabelSize = copyFrom.mLabelSize; + mLargeLetterSize = copyFrom.mLargeLetterSize; + mHintLetterSize = copyFrom.mHintLetterSize; + mShiftedLetterHintSize = copyFrom.mShiftedLetterHintSize; + mHintLabelSize = copyFrom.mHintLabelSize; + mPreviewTextSize = copyFrom.mPreviewTextSize; + + mTextColor = copyFrom.mTextColor; + mTextInactivatedColor = copyFrom.mTextInactivatedColor; + mTextShadowColor = copyFrom.mTextShadowColor; + mFunctionalTextColor = copyFrom.mFunctionalTextColor; + mHintLetterColor = copyFrom.mHintLetterColor; + mHintLabelColor = copyFrom.mHintLabelColor; + mShiftedLetterHintInactivatedColor = copyFrom.mShiftedLetterHintInactivatedColor; + mShiftedLetterHintActivatedColor = copyFrom.mShiftedLetterHintActivatedColor; + mPreviewTextColor = copyFrom.mPreviewTextColor; + + mHintLabelVerticalAdjustment = copyFrom.mHintLabelVerticalAdjustment; + mLabelOffCenterRatio = copyFrom.mLabelOffCenterRatio; + mHintLabelOffCenterRatio = copyFrom.mHintLabelOffCenterRatio; + + mAnimAlpha = copyFrom.mAnimAlpha; + } + + public void updateParams(final int keyHeight, @Nullable final KeyVisualAttributes attr) { + if (attr == null) { + return; + } + + if (attr.mTypeface != null) { + mTypeface = attr.mTypeface; + } + + mLetterSize = selectTextSizeFromDimensionOrRatio(keyHeight, + attr.mLetterSize, attr.mLetterRatio, mLetterSize); + mLabelSize = selectTextSizeFromDimensionOrRatio(keyHeight, + attr.mLabelSize, attr.mLabelRatio, mLabelSize); + mLargeLetterSize = selectTextSize(keyHeight, attr.mLargeLetterRatio, mLargeLetterSize); + mHintLetterSize = selectTextSize(keyHeight, attr.mHintLetterRatio, mHintLetterSize); + mShiftedLetterHintSize = selectTextSize(keyHeight, + attr.mShiftedLetterHintRatio, mShiftedLetterHintSize); + mHintLabelSize = selectTextSize(keyHeight, attr.mHintLabelRatio, mHintLabelSize); + mPreviewTextSize = selectTextSize(keyHeight, attr.mPreviewTextRatio, mPreviewTextSize); + + mTextColor = selectColor(attr.mTextColor, mTextColor); + mTextInactivatedColor = selectColor(attr.mTextInactivatedColor, mTextInactivatedColor); + mTextShadowColor = selectColor(attr.mTextShadowColor, mTextShadowColor); + mFunctionalTextColor = selectColor(attr.mFunctionalTextColor, mFunctionalTextColor); + mHintLetterColor = selectColor(attr.mHintLetterColor, mHintLetterColor); + mHintLabelColor = selectColor(attr.mHintLabelColor, mHintLabelColor); + mShiftedLetterHintInactivatedColor = selectColor( + attr.mShiftedLetterHintInactivatedColor, mShiftedLetterHintInactivatedColor); + mShiftedLetterHintActivatedColor = selectColor( + attr.mShiftedLetterHintActivatedColor, mShiftedLetterHintActivatedColor); + mPreviewTextColor = selectColor(attr.mPreviewTextColor, mPreviewTextColor); + + mHintLabelVerticalAdjustment = selectFloatIfNonZero( + attr.mHintLabelVerticalAdjustment, mHintLabelVerticalAdjustment); + mLabelOffCenterRatio = selectFloatIfNonZero( + attr.mLabelOffCenterRatio, mLabelOffCenterRatio); + mHintLabelOffCenterRatio = selectFloatIfNonZero( + attr.mHintLabelOffCenterRatio, mHintLabelOffCenterRatio); + } + + @Nonnull + public KeyDrawParams mayCloneAndUpdateParams(final int keyHeight, + @Nullable final KeyVisualAttributes attr) { + if (attr == null) { + return this; + } + final KeyDrawParams newParams = new KeyDrawParams(this); + newParams.updateParams(keyHeight, attr); + return newParams; + } + + private static int selectTextSizeFromDimensionOrRatio(final int keyHeight, + final int dimens, final float ratio, final int defaultDimens) { + if (ResourceUtils.isValidDimensionPixelSize(dimens)) { + return dimens; + } + if (ResourceUtils.isValidFraction(ratio)) { + return (int)(keyHeight * ratio); + } + return defaultDimens; + } + + private static int selectTextSize(final int keyHeight, final float ratio, + final int defaultSize) { + if (ResourceUtils.isValidFraction(ratio)) { + return (int)(keyHeight * ratio); + } + return defaultSize; + } + + private static int selectColor(final int attrColor, final int defaultColor) { + if (attrColor != 0) { + return attrColor; + } + return defaultColor; + } + + private static float selectFloatIfNonZero(final float attrFloat, final float defaultFloat) { + if (attrFloat != 0) { + return attrFloat; + } + return defaultFloat; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewChoreographer.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewChoreographer.java new file mode 100644 index 000000000..ebdde32c0 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewChoreographer.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.utils.ViewLayoutUtils; + +import java.util.ArrayDeque; +import java.util.HashMap; + +/** + * This class controls pop up key previews. This class decides: + * - what kind of key previews should be shown. + * - where key previews should be placed. + * - how key previews should be shown and dismissed. + */ +public final class KeyPreviewChoreographer { + // Free {@link KeyPreviewView} pool that can be used for key preview. + private final ArrayDeque<KeyPreviewView> mFreeKeyPreviewViews = new ArrayDeque<>(); + // Map from {@link Key} to {@link KeyPreviewView} that is currently being displayed as key + // preview. + private final HashMap<Key,KeyPreviewView> mShowingKeyPreviewViews = new HashMap<>(); + + private final KeyPreviewDrawParams mParams; + + public KeyPreviewChoreographer(final KeyPreviewDrawParams params) { + mParams = params; + } + + public KeyPreviewView getKeyPreviewView(final Key key, final ViewGroup placerView) { + KeyPreviewView keyPreviewView = mShowingKeyPreviewViews.remove(key); + if (keyPreviewView != null) { + return keyPreviewView; + } + keyPreviewView = mFreeKeyPreviewViews.poll(); + if (keyPreviewView != null) { + return keyPreviewView; + } + final Context context = placerView.getContext(); + keyPreviewView = new KeyPreviewView(context, null /* attrs */); + keyPreviewView.setBackgroundResource(mParams.mPreviewBackgroundResId); + placerView.addView(keyPreviewView, ViewLayoutUtils.newLayoutParam(placerView, 0, 0)); + return keyPreviewView; + } + + public boolean isShowingKeyPreview(final Key key) { + return mShowingKeyPreviewViews.containsKey(key); + } + + public void dismissKeyPreview(final Key key, final boolean withAnimation) { + if (key == null) { + return; + } + final KeyPreviewView keyPreviewView = mShowingKeyPreviewViews.get(key); + if (keyPreviewView == null) { + return; + } + final Object tag = keyPreviewView.getTag(); + if (withAnimation) { + if (tag instanceof KeyPreviewAnimators) { + final KeyPreviewAnimators animators = (KeyPreviewAnimators)tag; + animators.startDismiss(); + return; + } + } + // Dismiss preview without animation. + mShowingKeyPreviewViews.remove(key); + if (tag instanceof Animator) { + ((Animator)tag).cancel(); + } + keyPreviewView.setTag(null); + keyPreviewView.setVisibility(View.INVISIBLE); + mFreeKeyPreviewViews.add(keyPreviewView); + } + + public void placeAndShowKeyPreview(final Key key, final KeyboardIconsSet iconsSet, + final KeyDrawParams drawParams, final int keyboardViewWidth, final int[] keyboardOrigin, + final ViewGroup placerView, final boolean withAnimation) { + final KeyPreviewView keyPreviewView = getKeyPreviewView(key, placerView); + placeKeyPreview( + key, keyPreviewView, iconsSet, drawParams, keyboardViewWidth, keyboardOrigin); + showKeyPreview(key, keyPreviewView, withAnimation); + } + + private void placeKeyPreview(final Key key, final KeyPreviewView keyPreviewView, + final KeyboardIconsSet iconsSet, final KeyDrawParams drawParams, + final int keyboardViewWidth, final int[] originCoords) { + keyPreviewView.setPreviewVisual(key, iconsSet, drawParams); + keyPreviewView.measure( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + mParams.setGeometry(keyPreviewView); + final int previewWidth = keyPreviewView.getMeasuredWidth(); + final int previewHeight = mParams.mPreviewHeight; + final int keyDrawWidth = key.getDrawWidth(); + // The key preview is horizontally aligned with the center of the visible part of the + // parent key. If it doesn't fit in this {@link KeyboardView}, it is moved inward to fit and + // the left/right background is used if such background is specified. + final int keyPreviewPosition; + int previewX = key.getDrawX() - (previewWidth - keyDrawWidth) / 2 + + CoordinateUtils.x(originCoords); + if (previewX < 0) { + previewX = 0; + keyPreviewPosition = KeyPreviewView.POSITION_LEFT; + } else if (previewX > keyboardViewWidth - previewWidth) { + previewX = keyboardViewWidth - previewWidth; + keyPreviewPosition = KeyPreviewView.POSITION_RIGHT; + } else { + keyPreviewPosition = KeyPreviewView.POSITION_MIDDLE; + } + final boolean hasMoreKeys = (key.getMoreKeys() != null); + keyPreviewView.setPreviewBackground(hasMoreKeys, keyPreviewPosition); + // The key preview is placed vertically above the top edge of the parent key with an + // arbitrary offset. + final int previewY = key.getY() - previewHeight + mParams.mPreviewOffset + + CoordinateUtils.y(originCoords); + + ViewLayoutUtils.placeViewAt( + keyPreviewView, previewX, previewY, previewWidth, previewHeight); + keyPreviewView.setPivotX(previewWidth / 2.0f); + keyPreviewView.setPivotY(previewHeight); + } + + void showKeyPreview(final Key key, final KeyPreviewView keyPreviewView, + final boolean withAnimation) { + if (!withAnimation) { + keyPreviewView.setVisibility(View.VISIBLE); + mShowingKeyPreviewViews.put(key, keyPreviewView); + return; + } + + // Show preview with animation. + final Animator showUpAnimator = createShowUpAnimator(key, keyPreviewView); + final Animator dismissAnimator = createDismissAnimator(key, keyPreviewView); + final KeyPreviewAnimators animators = new KeyPreviewAnimators( + showUpAnimator, dismissAnimator); + keyPreviewView.setTag(animators); + animators.startShowUp(); + } + + public Animator createShowUpAnimator(final Key key, final KeyPreviewView keyPreviewView) { + final Animator showUpAnimator = mParams.createShowUpAnimator(keyPreviewView); + showUpAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(final Animator animator) { + showKeyPreview(key, keyPreviewView, false /* withAnimation */); + } + }); + return showUpAnimator; + } + + private Animator createDismissAnimator(final Key key, final KeyPreviewView keyPreviewView) { + final Animator dismissAnimator = mParams.createDismissAnimator(keyPreviewView); + dismissAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animator) { + dismissKeyPreview(key, false /* withAnimation */); + } + }); + return dismissAnimator; + } + + private static class KeyPreviewAnimators extends AnimatorListenerAdapter { + private final Animator mShowUpAnimator; + private final Animator mDismissAnimator; + + public KeyPreviewAnimators(final Animator showUpAnimator, final Animator dismissAnimator) { + mShowUpAnimator = showUpAnimator; + mDismissAnimator = dismissAnimator; + } + + public void startShowUp() { + mShowUpAnimator.start(); + } + + public void startDismiss() { + if (mShowUpAnimator.isRunning()) { + mShowUpAnimator.addListener(this); + return; + } + mDismissAnimator.start(); + } + + @Override + public void onAnimationEnd(final Animator animator) { + mDismissAnimator.start(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewDrawParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewDrawParams.java new file mode 100644 index 000000000..bcfea36e1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewDrawParams.java @@ -0,0 +1,188 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.animation.Animator; +import android.animation.AnimatorInflater; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.res.TypedArray; +import android.view.View; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.DecelerateInterpolator; + +import org.kelar.inputmethod.latin.R; + +public final class KeyPreviewDrawParams { + // XML attributes of {@link MainKeyboardView}. + public final int mPreviewOffset; + public final int mPreviewHeight; + public final int mPreviewBackgroundResId; + private final int mShowUpAnimatorResId; + private final int mDismissAnimatorResId; + private boolean mHasCustomAnimationParams; + private int mShowUpDuration; + private int mDismissDuration; + private float mShowUpStartXScale; + private float mShowUpStartYScale; + private float mDismissEndXScale; + private float mDismissEndYScale; + private int mLingerTimeout; + private boolean mShowPopup = true; + + // The graphical geometry of the key preview. + // <-width-> + // +-------+ ^ + // | | | + // |preview| height (visible) + // | | | + // + + ^ v + // \ / |offset + // +-\ /-+ v + // | +-+ | + // |parent | + // | key| + // +-------+ + // The background of a {@link TextView} being used for a key preview may have invisible + // paddings. To align the more keys keyboard panel's visible part with the visible part of + // the background, we need to record the width and height of key preview that don't include + // invisible paddings. + private int mVisibleWidth; + private int mVisibleHeight; + // The key preview may have an arbitrary offset and its background that may have a bottom + // padding. To align the more keys keyboard and the key preview we also need to record the + // offset between the top edge of parent key and the bottom of the visible part of key + // preview background. + private int mVisibleOffset; + + public KeyPreviewDrawParams(final TypedArray mainKeyboardViewAttr) { + mPreviewOffset = mainKeyboardViewAttr.getDimensionPixelOffset( + R.styleable.MainKeyboardView_keyPreviewOffset, 0); + mPreviewHeight = mainKeyboardViewAttr.getDimensionPixelSize( + R.styleable.MainKeyboardView_keyPreviewHeight, 0); + mPreviewBackgroundResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_keyPreviewBackground, 0); + mLingerTimeout = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_keyPreviewLingerTimeout, 0); + mShowUpAnimatorResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_keyPreviewShowUpAnimator, 0); + mDismissAnimatorResId = mainKeyboardViewAttr.getResourceId( + R.styleable.MainKeyboardView_keyPreviewDismissAnimator, 0); + } + + public void setVisibleOffset(final int previewVisibleOffset) { + mVisibleOffset = previewVisibleOffset; + } + + public int getVisibleOffset() { + return mVisibleOffset; + } + + public void setGeometry(final View previewTextView) { + final int previewWidth = previewTextView.getMeasuredWidth(); + final int previewHeight = mPreviewHeight; + // The width and height of visible part of the key preview background. The content marker + // of the background 9-patch have to cover the visible part of the background. + mVisibleWidth = previewWidth - previewTextView.getPaddingLeft() + - previewTextView.getPaddingRight(); + mVisibleHeight = previewHeight - previewTextView.getPaddingTop() + - previewTextView.getPaddingBottom(); + // The distance between the top edge of the parent key and the bottom of the visible part + // of the key preview background. + setVisibleOffset(mPreviewOffset - previewTextView.getPaddingBottom()); + } + + public int getVisibleWidth() { + return mVisibleWidth; + } + + public int getVisibleHeight() { + return mVisibleHeight; + } + + public void setPopupEnabled(final boolean enabled, final int lingerTimeout) { + mShowPopup = enabled; + mLingerTimeout = lingerTimeout; + } + + public boolean isPopupEnabled() { + return mShowPopup; + } + + public int getLingerTimeout() { + return mLingerTimeout; + } + + public void setAnimationParams(final boolean hasCustomAnimationParams, + final float showUpStartXScale, final float showUpStartYScale, final int showUpDuration, + final float dismissEndXScale, final float dismissEndYScale, final int dismissDuration) { + mHasCustomAnimationParams = hasCustomAnimationParams; + mShowUpStartXScale = showUpStartXScale; + mShowUpStartYScale = showUpStartYScale; + mShowUpDuration = showUpDuration; + mDismissEndXScale = dismissEndXScale; + mDismissEndYScale = dismissEndYScale; + mDismissDuration = dismissDuration; + } + + private static final float KEY_PREVIEW_SHOW_UP_END_SCALE = 1.0f; + private static final AccelerateInterpolator ACCELERATE_INTERPOLATOR = + new AccelerateInterpolator(); + private static final DecelerateInterpolator DECELERATE_INTERPOLATOR = + new DecelerateInterpolator(); + + public Animator createShowUpAnimator(final View target) { + if (mHasCustomAnimationParams) { + final ObjectAnimator scaleXAnimator = ObjectAnimator.ofFloat( + target, View.SCALE_X, mShowUpStartXScale, + KEY_PREVIEW_SHOW_UP_END_SCALE); + final ObjectAnimator scaleYAnimator = ObjectAnimator.ofFloat( + target, View.SCALE_Y, mShowUpStartYScale, + KEY_PREVIEW_SHOW_UP_END_SCALE); + final AnimatorSet showUpAnimator = new AnimatorSet(); + showUpAnimator.play(scaleXAnimator).with(scaleYAnimator); + showUpAnimator.setDuration(mShowUpDuration); + showUpAnimator.setInterpolator(DECELERATE_INTERPOLATOR); + return showUpAnimator; + } + final Animator animator = AnimatorInflater.loadAnimator( + target.getContext(), mShowUpAnimatorResId); + animator.setTarget(target); + animator.setInterpolator(DECELERATE_INTERPOLATOR); + return animator; + } + + public Animator createDismissAnimator(final View target) { + if (mHasCustomAnimationParams) { + final ObjectAnimator scaleXAnimator = ObjectAnimator.ofFloat( + target, View.SCALE_X, mDismissEndXScale); + final ObjectAnimator scaleYAnimator = ObjectAnimator.ofFloat( + target, View.SCALE_Y, mDismissEndYScale); + final AnimatorSet dismissAnimator = new AnimatorSet(); + dismissAnimator.play(scaleXAnimator).with(scaleYAnimator); + final int dismissDuration = Math.min(mDismissDuration, mLingerTimeout); + dismissAnimator.setDuration(dismissDuration); + dismissAnimator.setInterpolator(ACCELERATE_INTERPOLATOR); + return dismissAnimator; + } + final Animator animator = AnimatorInflater.loadAnimator( + target.getContext(), mDismissAnimatorResId); + animator.setTarget(target); + animator.setInterpolator(ACCELERATE_INTERPOLATOR); + return animator; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewView.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewView.java new file mode 100644 index 000000000..fa95a69ec --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewView.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.Context; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.TextView; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.latin.R; + +import java.util.HashSet; + +/** + * The pop up key preview view. + */ +public class KeyPreviewView extends TextView { + public static final int POSITION_MIDDLE = 0; + public static final int POSITION_LEFT = 1; + public static final int POSITION_RIGHT = 2; + + private final Rect mBackgroundPadding = new Rect(); + private static final HashSet<String> sNoScaleXTextSet = new HashSet<>(); + + public KeyPreviewView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public KeyPreviewView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + setGravity(Gravity.CENTER); + } + + public void setPreviewVisual(final Key key, final KeyboardIconsSet iconsSet, + final KeyDrawParams drawParams) { + // What we show as preview should match what we show on a key top in onDraw(). + final int iconId = key.getIconId(); + if (iconId != KeyboardIconsSet.ICON_UNDEFINED) { + setCompoundDrawables(null, null, null, key.getPreviewIcon(iconsSet)); + setText(null); + return; + } + + setCompoundDrawables(null, null, null, null); + setTextColor(drawParams.mPreviewTextColor); + setTextSize(TypedValue.COMPLEX_UNIT_PX, key.selectPreviewTextSize(drawParams)); + setTypeface(key.selectPreviewTypeface(drawParams)); + // TODO Should take care of temporaryShiftLabel here. + setTextAndScaleX(key.getPreviewLabel()); + } + + private void setTextAndScaleX(final String text) { + setTextScaleX(1.0f); + setText(text); + if (sNoScaleXTextSet.contains(text)) { + return; + } + // TODO: Override {@link #setBackground(Drawable)} that is supported from API 16 and + // calculate maximum text width. + final Drawable background = getBackground(); + if (background == null) { + return; + } + background.getPadding(mBackgroundPadding); + final int maxWidth = background.getIntrinsicWidth() - mBackgroundPadding.left + - mBackgroundPadding.right; + final float width = getTextWidth(text, getPaint()); + if (width <= maxWidth) { + sNoScaleXTextSet.add(text); + return; + } + setTextScaleX(maxWidth / width); + } + + public static void clearTextCache() { + sNoScaleXTextSet.clear(); + } + + private static float getTextWidth(final String text, final TextPaint paint) { + if (TextUtils.isEmpty(text)) { + return 0.0f; + } + final int len = text.length(); + final float[] widths = new float[len]; + final int count = paint.getTextWidths(text, 0, len, widths); + float width = 0; + for (int i = 0; i < count; i++) { + width += widths[i]; + } + return width; + } + + // Background state set + private static final int[][][] KEY_PREVIEW_BACKGROUND_STATE_TABLE = { + { // POSITION_MIDDLE + {}, + { R.attr.state_has_morekeys } + }, + { // POSITION_LEFT + { R.attr.state_left_edge }, + { R.attr.state_left_edge, R.attr.state_has_morekeys } + }, + { // POSITION_RIGHT + { R.attr.state_right_edge }, + { R.attr.state_right_edge, R.attr.state_has_morekeys } + } + }; + private static final int STATE_NORMAL = 0; + private static final int STATE_HAS_MOREKEYS = 1; + + public void setPreviewBackground(final boolean hasMoreKeys, final int position) { + final Drawable background = getBackground(); + if (background == null) { + return; + } + final int hasMoreKeysState = hasMoreKeys ? STATE_HAS_MOREKEYS : STATE_NORMAL; + background.setState(KEY_PREVIEW_BACKGROUND_STATE_TABLE[position][hasMoreKeysState]); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeySpecParser.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeySpecParser.java new file mode 100644 index 000000000..9ca6d09af --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeySpecParser.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import static org.kelar.inputmethod.latin.common.Constants.CODE_OUTPUT_TEXT; +import static org.kelar.inputmethod.latin.common.Constants.CODE_UNSPECIFIED; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * The string parser of the key specification. + * + * Each key specification is one of the following: + * - Label optionally followed by keyOutputText (keyLabel|keyOutputText). + * - Label optionally followed by code point (keyLabel|!code/code_name). + * - Icon followed by keyOutputText (!icon/icon_name|keyOutputText). + * - Icon followed by code point (!icon/icon_name|!code/code_name). + * Label and keyOutputText are one of the following: + * - Literal string. + * - Label reference represented by (!text/label_name), see {@link KeyboardTextsSet}. + * - String resource reference represented by (!text/resource_name), see {@link KeyboardTextsSet}. + * Icon is represented by (!icon/icon_name), see {@link KeyboardIconsSet}. + * Code is one of the following: + * - Code point presented by hexadecimal string prefixed with "0x" + * - Code reference represented by (!code/code_name), see {@link KeyboardCodesSet}. + * Special character, comma ',' backslash '\', and bar '|' can be escaped by '\' character. + * Note that the '\' is also parsed by XML parser and {@link MoreKeySpec#splitKeySpecs(String)} + * as well. + */ +// TODO: Rename to KeySpec and make this class to the key specification object. +public final class KeySpecParser { + // Constants for parsing. + private static final char BACKSLASH = Constants.CODE_BACKSLASH; + private static final char VERTICAL_BAR = Constants.CODE_VERTICAL_BAR; + private static final String PREFIX_HEX = "0x"; + + private KeySpecParser() { + // Intentional empty constructor for utility class. + } + + private static boolean hasIcon(@Nonnull final String keySpec) { + return keySpec.startsWith(KeyboardIconsSet.PREFIX_ICON); + } + + private static boolean hasCode(@Nonnull final String keySpec, final int labelEnd) { + if (labelEnd <= 0 || labelEnd + 1 >= keySpec.length()) { + return false; + } + if (keySpec.startsWith(KeyboardCodesSet.PREFIX_CODE, labelEnd + 1)) { + return true; + } + // This is a workaround to have a key that has a supplementary code point. We can't put a + // string in resource as a XML entity of a supplementary code point or a surrogate pair. + if (keySpec.startsWith(PREFIX_HEX, labelEnd + 1)) { + return true; + } + return false; + } + + @Nonnull + private static String parseEscape(@Nonnull final String text) { + if (text.indexOf(BACKSLASH) < 0) { + return text; + } + final int length = text.length(); + final StringBuilder sb = new StringBuilder(); + for (int pos = 0; pos < length; pos++) { + final char c = text.charAt(pos); + if (c == BACKSLASH && pos + 1 < length) { + // Skip escape char + pos++; + sb.append(text.charAt(pos)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static int indexOfLabelEnd(@Nonnull final String keySpec) { + final int length = keySpec.length(); + if (keySpec.indexOf(BACKSLASH) < 0) { + final int labelEnd = keySpec.indexOf(VERTICAL_BAR); + if (labelEnd == 0) { + if (length == 1) { + // Treat a sole vertical bar as a special case of key label. + return -1; + } + throw new KeySpecParserError("Empty label"); + } + return labelEnd; + } + for (int pos = 0; pos < length; pos++) { + final char c = keySpec.charAt(pos); + if (c == BACKSLASH && pos + 1 < length) { + // Skip escape char + pos++; + } else if (c == VERTICAL_BAR) { + return pos; + } + } + return -1; + } + + @Nonnull + private static String getBeforeLabelEnd(@Nonnull final String keySpec, final int labelEnd) { + return (labelEnd < 0) ? keySpec : keySpec.substring(0, labelEnd); + } + + @Nonnull + private static String getAfterLabelEnd(@Nonnull final String keySpec, final int labelEnd) { + return keySpec.substring(labelEnd + /* VERTICAL_BAR */1); + } + + private static void checkDoubleLabelEnd(@Nonnull final String keySpec, final int labelEnd) { + if (indexOfLabelEnd(getAfterLabelEnd(keySpec, labelEnd)) < 0) { + return; + } + throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec); + } + + @Nullable + public static String getLabel(@Nullable final String keySpec) { + if (keySpec == null) { + // TODO: Throw {@link KeySpecParserError} once Key.keyLabel attribute becomes mandatory. + return null; + } + if (hasIcon(keySpec)) { + return null; + } + final int labelEnd = indexOfLabelEnd(keySpec); + final String label = parseEscape(getBeforeLabelEnd(keySpec, labelEnd)); + if (label.isEmpty()) { + throw new KeySpecParserError("Empty label: " + keySpec); + } + return label; + } + + @Nullable + private static String getOutputTextInternal(@Nonnull final String keySpec, final int labelEnd) { + if (labelEnd <= 0) { + return null; + } + checkDoubleLabelEnd(keySpec, labelEnd); + return parseEscape(getAfterLabelEnd(keySpec, labelEnd)); + } + + @Nullable + public static String getOutputText(@Nullable final String keySpec) { + if (keySpec == null) { + // TODO: Throw {@link KeySpecParserError} once Key.keyLabel attribute becomes mandatory. + return null; + } + final int labelEnd = indexOfLabelEnd(keySpec); + if (hasCode(keySpec, labelEnd)) { + return null; + } + final String outputText = getOutputTextInternal(keySpec, labelEnd); + if (outputText != null) { + if (StringUtils.codePointCount(outputText) == 1) { + // If output text is one code point, it should be treated as a code. + // See {@link #getCode(Resources, String)}. + return null; + } + if (outputText.isEmpty()) { + throw new KeySpecParserError("Empty outputText: " + keySpec); + } + return outputText; + } + final String label = getLabel(keySpec); + if (label == null) { + throw new KeySpecParserError("Empty label: " + keySpec); + } + // Code is automatically generated for one letter label. See {@link getCode()}. + return (StringUtils.codePointCount(label) == 1) ? null : label; + } + + public static int getCode(@Nullable final String keySpec) { + if (keySpec == null) { + // TODO: Throw {@link KeySpecParserError} once Key.keyLabel attribute becomes mandatory. + return CODE_UNSPECIFIED; + } + final int labelEnd = indexOfLabelEnd(keySpec); + if (hasCode(keySpec, labelEnd)) { + checkDoubleLabelEnd(keySpec, labelEnd); + return parseCode(getAfterLabelEnd(keySpec, labelEnd), CODE_UNSPECIFIED); + } + final String outputText = getOutputTextInternal(keySpec, labelEnd); + if (outputText != null) { + // If output text is one code point, it should be treated as a code. + // See {@link #getOutputText(String)}. + if (StringUtils.codePointCount(outputText) == 1) { + return outputText.codePointAt(0); + } + return CODE_OUTPUT_TEXT; + } + final String label = getLabel(keySpec); + if (label == null) { + throw new KeySpecParserError("Empty label: " + keySpec); + } + // Code is automatically generated for one letter label. + return (StringUtils.codePointCount(label) == 1) ? label.codePointAt(0) : CODE_OUTPUT_TEXT; + } + + public static int parseCode(@Nullable final String text, final int defaultCode) { + if (text == null) { + return defaultCode; + } + if (text.startsWith(KeyboardCodesSet.PREFIX_CODE)) { + return KeyboardCodesSet.getCode(text.substring(KeyboardCodesSet.PREFIX_CODE.length())); + } + // This is a workaround to have a key that has a supplementary code point. We can't put a + // string in resource as a XML entity of a supplementary code point or a surrogate pair. + if (text.startsWith(PREFIX_HEX)) { + return Integer.parseInt(text.substring(PREFIX_HEX.length()), 16); + } + return defaultCode; + } + + public static int getIconId(@Nullable final String keySpec) { + if (keySpec == null) { + // TODO: Throw {@link KeySpecParserError} once Key.keyLabel attribute becomes mandatory. + return KeyboardIconsSet.ICON_UNDEFINED; + } + if (!hasIcon(keySpec)) { + return KeyboardIconsSet.ICON_UNDEFINED; + } + final int labelEnd = indexOfLabelEnd(keySpec); + final String iconName = getBeforeLabelEnd(keySpec, labelEnd) + .substring(KeyboardIconsSet.PREFIX_ICON.length()); + return KeyboardIconsSet.getIconId(iconName); + } + + @SuppressWarnings("serial") + public static final class KeySpecParserError extends RuntimeException { + public KeySpecParserError(final String message) { + super(message); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyStyle.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyStyle.java new file mode 100644 index 000000000..1ba45f1e3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyStyle.java @@ -0,0 +1,52 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public abstract class KeyStyle { + private final KeyboardTextsSet mTextsSet; + + public abstract @Nullable String[] getStringArray(TypedArray a, int index); + public abstract @Nullable String getString(TypedArray a, int index); + public abstract int getInt(TypedArray a, int index, int defaultValue); + public abstract int getFlags(TypedArray a, int index); + + protected KeyStyle(@Nonnull final KeyboardTextsSet textsSet) { + mTextsSet = textsSet; + } + + @Nullable + protected String parseString(final TypedArray a, final int index) { + if (a.hasValue(index)) { + return mTextsSet.resolveTextReference(a.getString(index)); + } + return null; + } + + @Nullable + protected String[] parseStringArray(final TypedArray a, final int index) { + if (a.hasValue(index)) { + final String text = mTextsSet.resolveTextReference(a.getString(index)); + return MoreKeySpec.splitKeySpecs(text); + } + return null; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyStylesSet.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyStylesSet.java new file mode 100644 index 000000000..cdfb22143 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyStylesSet.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; +import android.util.Log; +import android.util.SparseArray; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.XmlParseUtils; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.util.Arrays; +import java.util.HashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class KeyStylesSet { + private static final String TAG = KeyStylesSet.class.getSimpleName(); + private static final boolean DEBUG = false; + + @Nonnull + private final HashMap<String, KeyStyle> mStyles = new HashMap<>(); + + @Nonnull + private final KeyboardTextsSet mTextsSet; + @Nonnull + private final KeyStyle mEmptyKeyStyle; + @Nonnull + private static final String EMPTY_STYLE_NAME = "<empty>"; + + public KeyStylesSet(@Nonnull final KeyboardTextsSet textsSet) { + mTextsSet = textsSet; + mEmptyKeyStyle = new EmptyKeyStyle(textsSet); + mStyles.put(EMPTY_STYLE_NAME, mEmptyKeyStyle); + } + + private static final class EmptyKeyStyle extends KeyStyle { + EmptyKeyStyle(@Nonnull final KeyboardTextsSet textsSet) { + super(textsSet); + } + + @Override + @Nullable + public String[] getStringArray(final TypedArray a, final int index) { + return parseStringArray(a, index); + } + + @Override + @Nullable + public String getString(final TypedArray a, final int index) { + return parseString(a, index); + } + + @Override + public int getInt(final TypedArray a, final int index, final int defaultValue) { + return a.getInt(index, defaultValue); + } + + @Override + public int getFlags(final TypedArray a, final int index) { + return a.getInt(index, 0); + } + } + + private static final class DeclaredKeyStyle extends KeyStyle { + private final HashMap<String, KeyStyle> mStyles; + private final String mParentStyleName; + private final SparseArray<Object> mStyleAttributes = new SparseArray<>(); + + public DeclaredKeyStyle(@Nonnull final String parentStyleName, + @Nonnull final KeyboardTextsSet textsSet, + @Nonnull final HashMap<String, KeyStyle> styles) { + super(textsSet); + mParentStyleName = parentStyleName; + mStyles = styles; + } + + @Override + @Nullable + public String[] getStringArray(final TypedArray a, final int index) { + if (a.hasValue(index)) { + return parseStringArray(a, index); + } + final Object value = mStyleAttributes.get(index); + if (value != null) { + final String[] array = (String[])value; + return Arrays.copyOf(array, array.length); + } + final KeyStyle parentStyle = mStyles.get(mParentStyleName); + return parentStyle.getStringArray(a, index); + } + + @Override + @Nullable + public String getString(final TypedArray a, final int index) { + if (a.hasValue(index)) { + return parseString(a, index); + } + final Object value = mStyleAttributes.get(index); + if (value != null) { + return (String)value; + } + final KeyStyle parentStyle = mStyles.get(mParentStyleName); + return parentStyle.getString(a, index); + } + + @Override + public int getInt(final TypedArray a, final int index, final int defaultValue) { + if (a.hasValue(index)) { + return a.getInt(index, defaultValue); + } + final Object value = mStyleAttributes.get(index); + if (value != null) { + return (Integer)value; + } + final KeyStyle parentStyle = mStyles.get(mParentStyleName); + return parentStyle.getInt(a, index, defaultValue); + } + + @Override + public int getFlags(final TypedArray a, final int index) { + final int parentFlags = mStyles.get(mParentStyleName).getFlags(a, index); + final Integer value = (Integer)mStyleAttributes.get(index); + final int styleFlags = (value != null) ? value : 0; + final int flags = a.getInt(index, 0); + return flags | styleFlags | parentFlags; + } + + public void readKeyAttributes(final TypedArray keyAttr) { + // TODO: Currently not all Key attributes can be declared as style. + readString(keyAttr, R.styleable.Keyboard_Key_altCode); + readString(keyAttr, R.styleable.Keyboard_Key_keySpec); + readString(keyAttr, R.styleable.Keyboard_Key_keyHintLabel); + readStringArray(keyAttr, R.styleable.Keyboard_Key_moreKeys); + readStringArray(keyAttr, R.styleable.Keyboard_Key_additionalMoreKeys); + readFlags(keyAttr, R.styleable.Keyboard_Key_keyLabelFlags); + readString(keyAttr, R.styleable.Keyboard_Key_keyIconDisabled); + readInt(keyAttr, R.styleable.Keyboard_Key_maxMoreKeysColumn); + readInt(keyAttr, R.styleable.Keyboard_Key_backgroundType); + readFlags(keyAttr, R.styleable.Keyboard_Key_keyActionFlags); + } + + private void readString(final TypedArray a, final int index) { + if (a.hasValue(index)) { + mStyleAttributes.put(index, parseString(a, index)); + } + } + + private void readInt(final TypedArray a, final int index) { + if (a.hasValue(index)) { + mStyleAttributes.put(index, a.getInt(index, 0)); + } + } + + private void readFlags(final TypedArray a, final int index) { + if (a.hasValue(index)) { + final Integer value = (Integer)mStyleAttributes.get(index); + final int styleFlags = value != null ? value : 0; + mStyleAttributes.put(index, a.getInt(index, 0) | styleFlags); + } + } + + private void readStringArray(final TypedArray a, final int index) { + if (a.hasValue(index)) { + mStyleAttributes.put(index, parseStringArray(a, index)); + } + } + } + + public void parseKeyStyleAttributes(final TypedArray keyStyleAttr, final TypedArray keyAttrs, + final XmlPullParser parser) throws XmlPullParserException { + final String styleName = keyStyleAttr.getString(R.styleable.Keyboard_KeyStyle_styleName); + if (styleName == null) { + throw new XmlParseUtils.ParseException( + KeyboardBuilder.TAG_KEY_STYLE + " has no styleName attribute", parser); + } + if (DEBUG) { + Log.d(TAG, String.format("<%s styleName=%s />", + KeyboardBuilder.TAG_KEY_STYLE, styleName)); + if (mStyles.containsKey(styleName)) { + Log.d(TAG, KeyboardBuilder.TAG_KEY_STYLE + " " + styleName + " is overridden at " + + parser.getPositionDescription()); + } + } + + final String parentStyleInAttr = keyStyleAttr.getString( + R.styleable.Keyboard_KeyStyle_parentStyle); + if (parentStyleInAttr != null && !mStyles.containsKey(parentStyleInAttr)) { + throw new XmlParseUtils.ParseException( + "Unknown parentStyle " + parentStyleInAttr, parser); + } + final String parentStyleName = (parentStyleInAttr == null) ? EMPTY_STYLE_NAME + : parentStyleInAttr; + final DeclaredKeyStyle style = new DeclaredKeyStyle(parentStyleName, mTextsSet, mStyles); + style.readKeyAttributes(keyAttrs); + mStyles.put(styleName, style); + } + + @Nonnull + public KeyStyle getKeyStyle(final TypedArray keyAttr, final XmlPullParser parser) + throws XmlParseUtils.ParseException { + final String styleName = keyAttr.getString(R.styleable.Keyboard_Key_keyStyle); + if (styleName == null) { + return mEmptyKeyStyle; + } + final KeyStyle style = mStyles.get(styleName); + if (style == null) { + throw new XmlParseUtils.ParseException("Unknown key style: " + styleName, parser); + } + return style; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyVisualAttributes.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyVisualAttributes.java new file mode 100644 index 000000000..465656e13 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyVisualAttributes.java @@ -0,0 +1,148 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; +import android.graphics.Typeface; +import android.util.SparseIntArray; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class KeyVisualAttributes { + @Nullable + public final Typeface mTypeface; + + public final float mLetterRatio; + public final int mLetterSize; + public final float mLabelRatio; + public final int mLabelSize; + public final float mLargeLetterRatio; + public final float mHintLetterRatio; + public final float mShiftedLetterHintRatio; + public final float mHintLabelRatio; + public final float mPreviewTextRatio; + + public final int mTextColor; + public final int mTextInactivatedColor; + public final int mTextShadowColor; + public final int mFunctionalTextColor; + public final int mHintLetterColor; + public final int mHintLabelColor; + public final int mShiftedLetterHintInactivatedColor; + public final int mShiftedLetterHintActivatedColor; + public final int mPreviewTextColor; + + public final float mHintLabelVerticalAdjustment; + public final float mLabelOffCenterRatio; + public final float mHintLabelOffCenterRatio; + + private static final int[] VISUAL_ATTRIBUTE_IDS = { + R.styleable.Keyboard_Key_keyTypeface, + R.styleable.Keyboard_Key_keyLetterSize, + R.styleable.Keyboard_Key_keyLabelSize, + R.styleable.Keyboard_Key_keyLargeLetterRatio, + R.styleable.Keyboard_Key_keyHintLetterRatio, + R.styleable.Keyboard_Key_keyShiftedLetterHintRatio, + R.styleable.Keyboard_Key_keyHintLabelRatio, + R.styleable.Keyboard_Key_keyPreviewTextRatio, + R.styleable.Keyboard_Key_keyTextColor, + R.styleable.Keyboard_Key_keyTextInactivatedColor, + R.styleable.Keyboard_Key_keyTextShadowColor, + R.styleable.Keyboard_Key_functionalTextColor, + R.styleable.Keyboard_Key_keyHintLetterColor, + R.styleable.Keyboard_Key_keyHintLabelColor, + R.styleable.Keyboard_Key_keyShiftedLetterHintInactivatedColor, + R.styleable.Keyboard_Key_keyShiftedLetterHintActivatedColor, + R.styleable.Keyboard_Key_keyPreviewTextColor, + R.styleable.Keyboard_Key_keyHintLabelVerticalAdjustment, + R.styleable.Keyboard_Key_keyLabelOffCenterRatio, + R.styleable.Keyboard_Key_keyHintLabelOffCenterRatio + }; + private static final SparseIntArray sVisualAttributeIds = new SparseIntArray(); + private static final int ATTR_DEFINED = 1; + private static final int ATTR_NOT_FOUND = 0; + static { + for (final int attrId : VISUAL_ATTRIBUTE_IDS) { + sVisualAttributeIds.put(attrId, ATTR_DEFINED); + } + } + + @Nullable + public static KeyVisualAttributes newInstance(@Nonnull final TypedArray keyAttr) { + final int indexCount = keyAttr.getIndexCount(); + for (int i = 0; i < indexCount; i++) { + final int attrId = keyAttr.getIndex(i); + if (sVisualAttributeIds.get(attrId, ATTR_NOT_FOUND) == ATTR_NOT_FOUND) { + continue; + } + return new KeyVisualAttributes(keyAttr); + } + return null; + } + + private KeyVisualAttributes(@Nonnull final TypedArray keyAttr) { + if (keyAttr.hasValue(R.styleable.Keyboard_Key_keyTypeface)) { + mTypeface = Typeface.defaultFromStyle( + keyAttr.getInt(R.styleable.Keyboard_Key_keyTypeface, Typeface.NORMAL)); + } else { + mTypeface = null; + } + + mLetterRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyLetterSize); + mLetterSize = ResourceUtils.getDimensionPixelSize(keyAttr, + R.styleable.Keyboard_Key_keyLetterSize); + mLabelRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyLabelSize); + mLabelSize = ResourceUtils.getDimensionPixelSize(keyAttr, + R.styleable.Keyboard_Key_keyLabelSize); + mLargeLetterRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyLargeLetterRatio); + mHintLetterRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyHintLetterRatio); + mShiftedLetterHintRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyShiftedLetterHintRatio); + mHintLabelRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyHintLabelRatio); + mPreviewTextRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyPreviewTextRatio); + + mTextColor = keyAttr.getColor(R.styleable.Keyboard_Key_keyTextColor, 0); + mTextInactivatedColor = keyAttr.getColor( + R.styleable.Keyboard_Key_keyTextInactivatedColor, 0); + mTextShadowColor = keyAttr.getColor(R.styleable.Keyboard_Key_keyTextShadowColor, 0); + mFunctionalTextColor = keyAttr.getColor(R.styleable.Keyboard_Key_functionalTextColor, 0); + mHintLetterColor = keyAttr.getColor(R.styleable.Keyboard_Key_keyHintLetterColor, 0); + mHintLabelColor = keyAttr.getColor(R.styleable.Keyboard_Key_keyHintLabelColor, 0); + mShiftedLetterHintInactivatedColor = keyAttr.getColor( + R.styleable.Keyboard_Key_keyShiftedLetterHintInactivatedColor, 0); + mShiftedLetterHintActivatedColor = keyAttr.getColor( + R.styleable.Keyboard_Key_keyShiftedLetterHintActivatedColor, 0); + mPreviewTextColor = keyAttr.getColor(R.styleable.Keyboard_Key_keyPreviewTextColor, 0); + + mHintLabelVerticalAdjustment = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyHintLabelVerticalAdjustment, 0.0f); + mLabelOffCenterRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyLabelOffCenterRatio, 0.0f); + mHintLabelOffCenterRatio = ResourceUtils.getFraction(keyAttr, + R.styleable.Keyboard_Key_keyHintLabelOffCenterRatio, 0.0f); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardBuilder.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardBuilder.java new file mode 100644 index 000000000..80aa907cf --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardBuilder.java @@ -0,0 +1,889 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.os.Build; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.util.Xml; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.keyboard.KeyboardTheme; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.utils.ResourceUtils; +import org.kelar.inputmethod.latin.utils.XmlParseUtils; +import org.kelar.inputmethod.latin.utils.XmlParseUtils.ParseException; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; + +import javax.annotation.Nonnull; + +/** + * Keyboard Building helper. + * + * This class parses Keyboard XML file and eventually build a Keyboard. + * The Keyboard XML file looks like: + * <pre> + * <!-- xml/keyboard.xml --> + * <Keyboard keyboard_attributes*> + * <!-- Keyboard Content --> + * <Row row_attributes*> + * <!-- Row Content --> + * <Key key_attributes* /> + * <Spacer horizontalGap="32.0dp" /> + * <include keyboardLayout="@xml/other_keys"> + * ... + * </Row> + * <include keyboardLayout="@xml/other_rows"> + * ... + * </Keyboard> + * </pre> + * The XML file which is included in other file must have <merge> as root element, + * such as: + * <pre> + * <!-- xml/other_keys.xml --> + * <merge> + * <Key key_attributes* /> + * ... + * </merge> + * </pre> + * and + * <pre> + * <!-- xml/other_rows.xml --> + * <merge> + * <Row row_attributes*> + * <Key key_attributes* /> + * </Row> + * ... + * </merge> + * </pre> + * You can also use switch-case-default tags to select Rows and Keys. + * <pre> + * <switch> + * <case case_attribute*> + * <!-- Any valid tags at switch position --> + * </case> + * ... + * <default> + * <!-- Any valid tags at switch position --> + * </default> + * </switch> + * </pre> + * You can declare Key style and specify styles within Key tags. + * <pre> + * <switch> + * <case mode="email"> + * <key-style styleName="f1-key" parentStyle="modifier-key" + * keyLabel=".com" + * /> + * </case> + * <case mode="url"> + * <key-style styleName="f1-key" parentStyle="modifier-key" + * keyLabel="http://" + * /> + * </case> + * </switch> + * ... + * <Key keyStyle="shift-key" ... /> + * </pre> + */ + +// TODO: Write unit tests for this class. +public class KeyboardBuilder<KP extends KeyboardParams> { + private static final String BUILDER_TAG = "Keyboard.Builder"; + private static final boolean DEBUG = false; + + // Keyboard XML Tags + private static final String TAG_KEYBOARD = "Keyboard"; + private static final String TAG_ROW = "Row"; + private static final String TAG_GRID_ROWS = "GridRows"; + private static final String TAG_KEY = "Key"; + private static final String TAG_SPACER = "Spacer"; + private static final String TAG_INCLUDE = "include"; + private static final String TAG_MERGE = "merge"; + private static final String TAG_SWITCH = "switch"; + private static final String TAG_CASE = "case"; + private static final String TAG_DEFAULT = "default"; + public static final String TAG_KEY_STYLE = "key-style"; + + private static final int DEFAULT_KEYBOARD_COLUMNS = 10; + private static final int DEFAULT_KEYBOARD_ROWS = 4; + + @Nonnull + protected final KP mParams; + protected final Context mContext; + protected final Resources mResources; + + private int mCurrentY = 0; + private KeyboardRow mCurrentRow = null; + private boolean mLeftEdge; + private boolean mTopEdge; + private Key mRightEdgeKey = null; + + public KeyboardBuilder(final Context context, @Nonnull final KP params) { + mContext = context; + final Resources res = context.getResources(); + mResources = res; + + mParams = params; + + params.GRID_WIDTH = res.getInteger(R.integer.config_keyboard_grid_width); + params.GRID_HEIGHT = res.getInteger(R.integer.config_keyboard_grid_height); + } + + public void setAllowRedundantMoreKes(final boolean enabled) { + mParams.mAllowRedundantMoreKeys = enabled; + } + + public KeyboardBuilder<KP> load(final int xmlId, final KeyboardId id) { + mParams.mId = id; + final XmlResourceParser parser = mResources.getXml(xmlId); + try { + parseKeyboard(parser); + } catch (XmlPullParserException e) { + Log.w(BUILDER_TAG, "keyboard XML parse error", e); + throw new IllegalArgumentException(e.getMessage(), e); + } catch (IOException e) { + Log.w(BUILDER_TAG, "keyboard XML parse error", e); + throw new RuntimeException(e.getMessage(), e); + } finally { + parser.close(); + } + return this; + } + + @UsedForTesting + public void disableTouchPositionCorrectionDataForTest() { + mParams.mTouchPositionCorrection.setEnabled(false); + } + + public void setProximityCharsCorrectionEnabled(final boolean enabled) { + mParams.mProximityCharsCorrectionEnabled = enabled; + } + + @Nonnull + public Keyboard build() { + return new Keyboard(mParams); + } + + private int mIndent; + private static final String SPACES = " "; + + private static String spaces(final int count) { + return (count < SPACES.length()) ? SPACES.substring(0, count) : SPACES; + } + + private void startTag(final String format, final Object ... args) { + Log.d(BUILDER_TAG, String.format(spaces(++mIndent * 2) + format, args)); + } + + private void endTag(final String format, final Object ... args) { + Log.d(BUILDER_TAG, String.format(spaces(mIndent-- * 2) + format, args)); + } + + private void startEndTag(final String format, final Object ... args) { + Log.d(BUILDER_TAG, String.format(spaces(++mIndent * 2) + format, args)); + mIndent--; + } + + private void parseKeyboard(final XmlPullParser parser) + throws XmlPullParserException, IOException { + if (DEBUG) startTag("<%s> %s", TAG_KEYBOARD, mParams.mId); + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_KEYBOARD.equals(tag)) { + parseKeyboardAttributes(parser); + startKeyboard(); + parseKeyboardContent(parser, false); + return; + } + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_KEYBOARD); + } + } + } + + private void parseKeyboardAttributes(final XmlPullParser parser) { + final AttributeSet attr = Xml.asAttributeSet(parser); + final TypedArray keyboardAttr = mContext.obtainStyledAttributes( + attr, R.styleable.Keyboard, R.attr.keyboardStyle, R.style.Keyboard); + final TypedArray keyAttr = mResources.obtainAttributes(attr, R.styleable.Keyboard_Key); + try { + final KeyboardParams params = mParams; + 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, baseHeight, baseHeight / DEFAULT_KEYBOARD_ROWS); + + params.mKeyVisualAttributes = KeyVisualAttributes.newInstance(keyAttr); + + params.mMoreKeysTemplate = keyboardAttr.getResourceId( + R.styleable.Keyboard_moreKeysTemplate, 0); + params.mMaxMoreKeysKeyboardColumn = keyAttr.getInt( + R.styleable.Keyboard_Key_maxMoreKeysColumn, 5); + + params.mThemeId = keyboardAttr.getInt(R.styleable.Keyboard_themeId, 0); + params.mIconsSet.loadIcons(keyboardAttr); + params.mTextsSet.setLocale(params.mId.getLocale(), mContext); + + final int resourceId = keyboardAttr.getResourceId( + R.styleable.Keyboard_touchPositionCorrectionData, 0); + if (resourceId != 0) { + final String[] data = mResources.getStringArray(resourceId); + params.mTouchPositionCorrection.load(data); + } + } finally { + keyAttr.recycle(); + keyboardAttr.recycle(); + } + } + + private void parseKeyboardContent(final XmlPullParser parser, final boolean skip) + throws XmlPullParserException, IOException { + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_ROW.equals(tag)) { + final KeyboardRow row = parseRowAttributes(parser); + if (DEBUG) startTag("<%s>%s", TAG_ROW, skip ? " skipped" : ""); + if (!skip) { + startRow(row); + } + parseRowContent(parser, row, skip); + } else if (TAG_GRID_ROWS.equals(tag)) { + if (DEBUG) startTag("<%s>%s", TAG_GRID_ROWS, skip ? " skipped" : ""); + parseGridRows(parser, skip); + } else if (TAG_INCLUDE.equals(tag)) { + parseIncludeKeyboardContent(parser, skip); + } else if (TAG_SWITCH.equals(tag)) { + parseSwitchKeyboardContent(parser, skip); + } else if (TAG_KEY_STYLE.equals(tag)) { + parseKeyStyle(parser, skip); + } else { + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_ROW); + } + } else if (event == XmlPullParser.END_TAG) { + final String tag = parser.getName(); + if (DEBUG) endTag("</%s>", tag); + if (TAG_KEYBOARD.equals(tag)) { + endKeyboard(); + return; + } + if (TAG_CASE.equals(tag) || TAG_DEFAULT.equals(tag) || TAG_MERGE.equals(tag)) { + return; + } + throw new XmlParseUtils.IllegalEndTag(parser, tag, TAG_ROW); + } + } + } + + private KeyboardRow parseRowAttributes(final XmlPullParser parser) + throws XmlPullParserException { + final AttributeSet attr = Xml.asAttributeSet(parser); + final TypedArray keyboardAttr = mResources.obtainAttributes(attr, R.styleable.Keyboard); + try { + if (keyboardAttr.hasValue(R.styleable.Keyboard_horizontalGap)) { + throw new XmlParseUtils.IllegalAttribute(parser, TAG_ROW, "horizontalGap"); + } + if (keyboardAttr.hasValue(R.styleable.Keyboard_verticalGap)) { + throw new XmlParseUtils.IllegalAttribute(parser, TAG_ROW, "verticalGap"); + } + return new KeyboardRow(mResources, mParams, parser, mCurrentY); + } finally { + keyboardAttr.recycle(); + } + } + + private void parseRowContent(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_KEY.equals(tag)) { + parseKey(parser, row, skip); + } else if (TAG_SPACER.equals(tag)) { + parseSpacer(parser, row, skip); + } else if (TAG_INCLUDE.equals(tag)) { + parseIncludeRowContent(parser, row, skip); + } else if (TAG_SWITCH.equals(tag)) { + parseSwitchRowContent(parser, row, skip); + } else if (TAG_KEY_STYLE.equals(tag)) { + parseKeyStyle(parser, skip); + } else { + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_ROW); + } + } else if (event == XmlPullParser.END_TAG) { + final String tag = parser.getName(); + if (DEBUG) endTag("</%s>", tag); + if (TAG_ROW.equals(tag)) { + if (!skip) { + endRow(row); + } + return; + } + if (TAG_CASE.equals(tag) || TAG_DEFAULT.equals(tag) || TAG_MERGE.equals(tag)) { + return; + } + throw new XmlParseUtils.IllegalEndTag(parser, tag, TAG_ROW); + } + } + } + + private void parseGridRows(final XmlPullParser parser, final boolean skip) + throws XmlPullParserException, IOException { + if (skip) { + XmlParseUtils.checkEndTag(TAG_GRID_ROWS, parser); + if (DEBUG) { + startEndTag("<%s /> skipped", TAG_GRID_ROWS); + } + return; + } + final KeyboardRow gridRows = new KeyboardRow(mResources, mParams, parser, mCurrentY); + final TypedArray gridRowAttr = mResources.obtainAttributes( + Xml.asAttributeSet(parser), R.styleable.Keyboard_GridRows); + final int codesArrayId = gridRowAttr.getResourceId( + R.styleable.Keyboard_GridRows_codesArray, 0); + final int textsArrayId = gridRowAttr.getResourceId( + R.styleable.Keyboard_GridRows_textsArray, 0); + gridRowAttr.recycle(); + if (codesArrayId == 0 && textsArrayId == 0) { + throw new XmlParseUtils.ParseException( + "Missing codesArray or textsArray attributes", parser); + } + if (codesArrayId != 0 && textsArrayId != 0) { + throw new XmlParseUtils.ParseException( + "Both codesArray and textsArray attributes specifed", parser); + } + final String[] array = mResources.getStringArray( + codesArrayId != 0 ? codesArrayId : textsArrayId); + final int counts = array.length; + final float keyWidth = gridRows.getKeyWidth(null, 0.0f); + final int numColumns = (int)(mParams.mOccupiedWidth / keyWidth); + for (int index = 0; index < counts; index += numColumns) { + final KeyboardRow row = new KeyboardRow(mResources, mParams, parser, mCurrentY); + startRow(row); + for (int c = 0; c < numColumns; c++) { + final int i = index + c; + if (i >= counts) { + break; + } + final String label; + final int code; + final String outputText; + final int supportedMinSdkVersion; + if (codesArrayId != 0) { + final String codeArraySpec = array[i]; + label = CodesArrayParser.parseLabel(codeArraySpec); + code = CodesArrayParser.parseCode(codeArraySpec); + outputText = CodesArrayParser.parseOutputText(codeArraySpec); + supportedMinSdkVersion = + CodesArrayParser.getMinSupportSdkVersion(codeArraySpec); + } else { + final String textArraySpec = array[i]; + // TODO: Utilize KeySpecParser or write more generic TextsArrayParser. + label = textArraySpec; + code = Constants.CODE_OUTPUT_TEXT; + outputText = textArraySpec + (char)Constants.CODE_SPACE; + supportedMinSdkVersion = 0; + } + if (Build.VERSION.SDK_INT < supportedMinSdkVersion) { + continue; + } + final int labelFlags = row.getDefaultKeyLabelFlags(); + // TODO: Should be able to assign default keyActionFlags as well. + final int backgroundType = row.getDefaultBackgroundType(); + final int x = (int)row.getKeyX(null); + final int y = row.getKeyY(); + final int width = (int)keyWidth; + final int height = row.getRowHeight(); + final Key key = new Key(label, KeyboardIconsSet.ICON_UNDEFINED, code, outputText, + null /* hintLabel */, labelFlags, backgroundType, x, y, width, height, + mParams.mHorizontalGap, mParams.mVerticalGap); + endKey(key); + row.advanceXPos(keyWidth); + } + endRow(row); + } + + XmlParseUtils.checkEndTag(TAG_GRID_ROWS, parser); + } + + private void parseKey(final XmlPullParser parser, final KeyboardRow row, final boolean skip) + throws XmlPullParserException, IOException { + if (skip) { + XmlParseUtils.checkEndTag(TAG_KEY, parser); + if (DEBUG) startEndTag("<%s /> skipped", TAG_KEY); + return; + } + final TypedArray keyAttr = mResources.obtainAttributes( + Xml.asAttributeSet(parser), R.styleable.Keyboard_Key); + final KeyStyle keyStyle = mParams.mKeyStyles.getKeyStyle(keyAttr, parser); + final String keySpec = keyStyle.getString(keyAttr, R.styleable.Keyboard_Key_keySpec); + if (TextUtils.isEmpty(keySpec)) { + throw new ParseException("Empty keySpec", parser); + } + final Key key = new Key(keySpec, keyAttr, keyStyle, mParams, row); + keyAttr.recycle(); + if (DEBUG) { + startEndTag("<%s%s %s moreKeys=%s />", TAG_KEY, (key.isEnabled() ? "" : " disabled"), + key, Arrays.toString(key.getMoreKeys())); + } + XmlParseUtils.checkEndTag(TAG_KEY, parser); + endKey(key); + } + + private void parseSpacer(final XmlPullParser parser, final KeyboardRow row, final boolean skip) + throws XmlPullParserException, IOException { + if (skip) { + XmlParseUtils.checkEndTag(TAG_SPACER, parser); + if (DEBUG) startEndTag("<%s /> skipped", TAG_SPACER); + return; + } + final TypedArray keyAttr = mResources.obtainAttributes( + Xml.asAttributeSet(parser), R.styleable.Keyboard_Key); + final KeyStyle keyStyle = mParams.mKeyStyles.getKeyStyle(keyAttr, parser); + final Key spacer = new Key.Spacer(keyAttr, keyStyle, mParams, row); + keyAttr.recycle(); + if (DEBUG) startEndTag("<%s />", TAG_SPACER); + XmlParseUtils.checkEndTag(TAG_SPACER, parser); + endKey(spacer); + } + + private void parseIncludeKeyboardContent(final XmlPullParser parser, final boolean skip) + throws XmlPullParserException, IOException { + parseIncludeInternal(parser, null, skip); + } + + private void parseIncludeRowContent(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + parseIncludeInternal(parser, row, skip); + } + + private void parseIncludeInternal(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + if (skip) { + XmlParseUtils.checkEndTag(TAG_INCLUDE, parser); + if (DEBUG) startEndTag("</%s> skipped", TAG_INCLUDE); + return; + } + final AttributeSet attr = Xml.asAttributeSet(parser); + final TypedArray keyboardAttr = mResources.obtainAttributes( + attr, R.styleable.Keyboard_Include); + final TypedArray keyAttr = mResources.obtainAttributes(attr, R.styleable.Keyboard_Key); + int keyboardLayout = 0; + try { + XmlParseUtils.checkAttributeExists( + keyboardAttr, R.styleable.Keyboard_Include_keyboardLayout, "keyboardLayout", + TAG_INCLUDE, parser); + keyboardLayout = keyboardAttr.getResourceId( + R.styleable.Keyboard_Include_keyboardLayout, 0); + if (row != null) { + // Override current x coordinate. + row.setXPos(row.getKeyX(keyAttr)); + // Push current Row attributes and update with new attributes. + row.pushRowAttributes(keyAttr); + } + } finally { + keyboardAttr.recycle(); + keyAttr.recycle(); + } + + XmlParseUtils.checkEndTag(TAG_INCLUDE, parser); + if (DEBUG) { + startEndTag("<%s keyboardLayout=%s />",TAG_INCLUDE, + mResources.getResourceEntryName(keyboardLayout)); + } + final XmlResourceParser parserForInclude = mResources.getXml(keyboardLayout); + try { + parseMerge(parserForInclude, row, skip); + } finally { + if (row != null) { + // Restore Row attributes. + row.popRowAttributes(); + } + parserForInclude.close(); + } + } + + private void parseMerge(final XmlPullParser parser, final KeyboardRow row, final boolean skip) + throws XmlPullParserException, IOException { + if (DEBUG) startTag("<%s>", TAG_MERGE); + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_MERGE.equals(tag)) { + if (row == null) { + parseKeyboardContent(parser, skip); + } else { + parseRowContent(parser, row, skip); + } + return; + } + throw new XmlParseUtils.ParseException( + "Included keyboard layout must have <merge> root element", parser); + } + } + } + + private void parseSwitchKeyboardContent(final XmlPullParser parser, final boolean skip) + throws XmlPullParserException, IOException { + parseSwitchInternal(parser, null, skip); + } + + private void parseSwitchRowContent(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + parseSwitchInternal(parser, row, skip); + } + + private void parseSwitchInternal(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + if (DEBUG) startTag("<%s> %s", TAG_SWITCH, mParams.mId); + boolean selected = false; + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + final int event = parser.next(); + if (event == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + if (TAG_CASE.equals(tag)) { + selected |= parseCase(parser, row, selected ? true : skip); + } else if (TAG_DEFAULT.equals(tag)) { + selected |= parseDefault(parser, row, selected ? true : skip); + } else { + throw new XmlParseUtils.IllegalStartTag(parser, tag, TAG_SWITCH); + } + } else if (event == XmlPullParser.END_TAG) { + final String tag = parser.getName(); + if (TAG_SWITCH.equals(tag)) { + if (DEBUG) endTag("</%s>", TAG_SWITCH); + return; + } + throw new XmlParseUtils.IllegalEndTag(parser, tag, TAG_SWITCH); + } + } + } + + private boolean parseCase(final XmlPullParser parser, final KeyboardRow row, final boolean skip) + throws XmlPullParserException, IOException { + final boolean selected = parseCaseCondition(parser); + if (row == null) { + // Processing Rows. + parseKeyboardContent(parser, selected ? skip : true); + } else { + // Processing Keys. + parseRowContent(parser, row, selected ? skip : true); + } + return selected; + } + + private boolean parseCaseCondition(final XmlPullParser parser) { + final KeyboardId id = mParams.mId; + if (id == null) { + return true; + } + final AttributeSet attr = Xml.asAttributeSet(parser); + final TypedArray caseAttr = mResources.obtainAttributes(attr, R.styleable.Keyboard_Case); + try { + final boolean keyboardLayoutSetMatched = matchString(caseAttr, + R.styleable.Keyboard_Case_keyboardLayoutSet, + id.mSubtype.getKeyboardLayoutSetName()); + final boolean keyboardLayoutSetElementMatched = matchTypedValue(caseAttr, + R.styleable.Keyboard_Case_keyboardLayoutSetElement, id.mElementId, + KeyboardId.elementIdToName(id.mElementId)); + final boolean keyboardThemeMacthed = matchTypedValue(caseAttr, + R.styleable.Keyboard_Case_keyboardTheme, mParams.mThemeId, + KeyboardTheme.getKeyboardThemeName(mParams.mThemeId)); + final boolean modeMatched = matchTypedValue(caseAttr, + R.styleable.Keyboard_Case_mode, id.mMode, KeyboardId.modeName(id.mMode)); + final boolean navigateNextMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_navigateNext, id.navigateNext()); + final boolean navigatePreviousMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_navigatePrevious, id.navigatePrevious()); + final boolean passwordInputMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_passwordInput, id.passwordInput()); + final boolean clobberSettingsKeyMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_clobberSettingsKey, id.mClobberSettingsKey); + final boolean hasShortcutKeyMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_hasShortcutKey, id.mHasShortcutKey); + final boolean languageSwitchKeyEnabledMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_languageSwitchKeyEnabled, + id.mLanguageSwitchKeyEnabled); + final boolean isMultiLineMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_isMultiLine, id.isMultiLine()); + final boolean imeActionMatched = matchInteger(caseAttr, + R.styleable.Keyboard_Case_imeAction, id.imeAction()); + final boolean isIconDefinedMatched = isIconDefined(caseAttr, + R.styleable.Keyboard_Case_isIconDefined, mParams.mIconsSet); + final Locale locale = id.getLocale(); + final boolean localeCodeMatched = matchLocaleCodes(caseAttr, locale); + final boolean languageCodeMatched = matchLanguageCodes(caseAttr, locale); + final boolean countryCodeMatched = matchCountryCodes(caseAttr, locale); + final boolean splitLayoutMatched = matchBoolean(caseAttr, + R.styleable.Keyboard_Case_isSplitLayout, id.mIsSplitLayout); + final boolean selected = keyboardLayoutSetMatched && keyboardLayoutSetElementMatched + && keyboardThemeMacthed && modeMatched && navigateNextMatched + && navigatePreviousMatched && passwordInputMatched && clobberSettingsKeyMatched + && hasShortcutKeyMatched && languageSwitchKeyEnabledMatched + && isMultiLineMatched && imeActionMatched && isIconDefinedMatched + && localeCodeMatched && languageCodeMatched && countryCodeMatched + && splitLayoutMatched; + + if (DEBUG) { + startTag("<%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s>%s", TAG_CASE, + textAttr(caseAttr.getString( + R.styleable.Keyboard_Case_keyboardLayoutSet), "keyboardLayoutSet"), + textAttr(caseAttr.getString( + R.styleable.Keyboard_Case_keyboardLayoutSetElement), + "keyboardLayoutSetElement"), + textAttr(caseAttr.getString( + R.styleable.Keyboard_Case_keyboardTheme), "keyboardTheme"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_mode), "mode"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_imeAction), + "imeAction"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_navigateNext, + "navigateNext"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_navigatePrevious, + "navigatePrevious"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_clobberSettingsKey, + "clobberSettingsKey"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_passwordInput, + "passwordInput"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_hasShortcutKey, + "hasShortcutKey"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_languageSwitchKeyEnabled, + "languageSwitchKeyEnabled"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_isMultiLine, + "isMultiLine"), + booleanAttr(caseAttr, R.styleable.Keyboard_Case_isSplitLayout, + "splitLayout"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_isIconDefined), + "isIconDefined"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_localeCode), + "localeCode"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_languageCode), + "languageCode"), + textAttr(caseAttr.getString(R.styleable.Keyboard_Case_countryCode), + "countryCode"), + selected ? "" : " skipped"); + } + + return selected; + } finally { + caseAttr.recycle(); + } + } + + private static boolean matchLocaleCodes(TypedArray caseAttr, final Locale locale) { + return matchString(caseAttr, R.styleable.Keyboard_Case_localeCode, locale.toString()); + } + + private static boolean matchLanguageCodes(TypedArray caseAttr, Locale locale) { + return matchString(caseAttr, R.styleable.Keyboard_Case_languageCode, locale.getLanguage()); + } + + private static boolean matchCountryCodes(TypedArray caseAttr, Locale locale) { + return matchString(caseAttr, R.styleable.Keyboard_Case_countryCode, locale.getCountry()); + } + + private static boolean matchInteger(final TypedArray a, final int index, final int value) { + // If <case> does not have "index" attribute, that means this <case> is wild-card for + // the attribute. + return !a.hasValue(index) || a.getInt(index, 0) == value; + } + + private static boolean matchBoolean(final TypedArray a, final int index, final boolean value) { + // If <case> does not have "index" attribute, that means this <case> is wild-card for + // the attribute. + return !a.hasValue(index) || a.getBoolean(index, false) == value; + } + + private static boolean matchString(final TypedArray a, final int index, final String value) { + // If <case> does not have "index" attribute, that means this <case> is wild-card for + // the attribute. + return !a.hasValue(index) + || StringUtils.containsInArray(value, a.getString(index).split("\\|")); + } + + private static boolean matchTypedValue(final TypedArray a, final int index, final int intValue, + final String strValue) { + // If <case> does not have "index" attribute, that means this <case> is wild-card for + // the attribute. + final TypedValue v = a.peekValue(index); + if (v == null) { + return true; + } + if (ResourceUtils.isIntegerValue(v)) { + return intValue == a.getInt(index, 0); + } + if (ResourceUtils.isStringValue(v)) { + return StringUtils.containsInArray(strValue, a.getString(index).split("\\|")); + } + return false; + } + + private static boolean isIconDefined(final TypedArray a, final int index, + final KeyboardIconsSet iconsSet) { + if (!a.hasValue(index)) { + return true; + } + final String iconName = a.getString(index); + final int iconId = KeyboardIconsSet.getIconId(iconName); + return iconsSet.getIconDrawable(iconId) != null; + } + + private boolean parseDefault(final XmlPullParser parser, final KeyboardRow row, + final boolean skip) throws XmlPullParserException, IOException { + if (DEBUG) startTag("<%s>", TAG_DEFAULT); + if (row == null) { + parseKeyboardContent(parser, skip); + } else { + parseRowContent(parser, row, skip); + } + return true; + } + + private void parseKeyStyle(final XmlPullParser parser, final boolean skip) + throws XmlPullParserException, IOException { + final AttributeSet attr = Xml.asAttributeSet(parser); + final TypedArray keyStyleAttr = mResources.obtainAttributes( + attr, R.styleable.Keyboard_KeyStyle); + final TypedArray keyAttrs = mResources.obtainAttributes(attr, R.styleable.Keyboard_Key); + try { + if (!keyStyleAttr.hasValue(R.styleable.Keyboard_KeyStyle_styleName)) { + throw new XmlParseUtils.ParseException("<" + TAG_KEY_STYLE + + "/> needs styleName attribute", parser); + } + if (DEBUG) { + startEndTag("<%s styleName=%s />%s", TAG_KEY_STYLE, + keyStyleAttr.getString(R.styleable.Keyboard_KeyStyle_styleName), + skip ? " skipped" : ""); + } + if (!skip) { + mParams.mKeyStyles.parseKeyStyleAttributes(keyStyleAttr, keyAttrs, parser); + } + } finally { + keyStyleAttr.recycle(); + keyAttrs.recycle(); + } + XmlParseUtils.checkEndTag(TAG_KEY_STYLE, parser); + } + + private void startKeyboard() { + mCurrentY += mParams.mTopPadding; + mTopEdge = true; + } + + private void startRow(final KeyboardRow row) { + addEdgeSpace(mParams.mLeftPadding, row); + mCurrentRow = row; + mLeftEdge = true; + mRightEdgeKey = null; + } + + private void endRow(final KeyboardRow row) { + if (mCurrentRow == null) { + throw new RuntimeException("orphan end row tag"); + } + if (mRightEdgeKey != null) { + mRightEdgeKey.markAsRightEdge(mParams); + mRightEdgeKey = null; + } + addEdgeSpace(mParams.mRightPadding, row); + mCurrentY += row.getRowHeight(); + mCurrentRow = null; + mTopEdge = false; + } + + private void endKey(@Nonnull final Key key) { + mParams.onAddKey(key); + if (mLeftEdge) { + key.markAsLeftEdge(mParams); + mLeftEdge = false; + } + if (mTopEdge) { + key.markAsTopEdge(mParams); + } + mRightEdgeKey = key; + } + + private void endKeyboard() { + mParams.removeRedundantMoreKeys(); + // {@link #parseGridRows(XmlPullParser,boolean)} may populate keyboard rows higher than + // previously expected. + final int actualHeight = mCurrentY - mParams.mVerticalGap + mParams.mBottomPadding; + mParams.mOccupiedHeight = Math.max(mParams.mOccupiedHeight, actualHeight); + } + + private void addEdgeSpace(final float width, final KeyboardRow row) { + row.advanceXPos(width); + mLeftEdge = false; + mRightEdgeKey = null; + } + + private static String textAttr(final String value, final String name) { + return value != null ? String.format(" %s=%s", name, value) : ""; + } + + private static String booleanAttr(final TypedArray a, final int index, final String name) { + return a.hasValue(index) + ? String.format(" %s=%s", name, a.getBoolean(index, false)) : ""; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardCodesSet.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardCodesSet.java new file mode 100644 index 000000000..0751069f8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardCodesSet.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.latin.common.Constants; + +import java.util.HashMap; + +public final class KeyboardCodesSet { + public static final String PREFIX_CODE = "!code/"; + + private static final HashMap<String, Integer> sNameToIdMap = new HashMap<>(); + + private KeyboardCodesSet() { + // This utility class is not publicly instantiable. + } + + public static int getCode(final String name) { + Integer id = sNameToIdMap.get(name); + if (id == null) throw new RuntimeException("Unknown key code: " + name); + return DEFAULT[id]; + } + + private static final String[] ID_TO_NAME = { + "key_tab", + "key_enter", + "key_space", + "key_shift", + "key_capslock", + "key_switch_alpha_symbol", + "key_output_text", + "key_delete", + "key_settings", + "key_shortcut", + "key_action_next", + "key_action_previous", + "key_shift_enter", + "key_language_switch", + "key_emoji", + "key_alpha_from_emoji", + "key_unspecified", + }; + + private static final int[] DEFAULT = { + Constants.CODE_TAB, + Constants.CODE_ENTER, + Constants.CODE_SPACE, + Constants.CODE_SHIFT, + Constants.CODE_CAPSLOCK, + Constants.CODE_SWITCH_ALPHA_SYMBOL, + Constants.CODE_OUTPUT_TEXT, + Constants.CODE_DELETE, + Constants.CODE_SETTINGS, + Constants.CODE_SHORTCUT, + Constants.CODE_ACTION_NEXT, + Constants.CODE_ACTION_PREVIOUS, + Constants.CODE_SHIFT_ENTER, + Constants.CODE_LANGUAGE_SWITCH, + Constants.CODE_EMOJI, + Constants.CODE_ALPHA_FROM_EMOJI, + Constants.CODE_UNSPECIFIED, + }; + + static { + for (int i = 0; i < ID_TO_NAME.length; i++) { + sNameToIdMap.put(ID_TO_NAME[i], i); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardIconsSet.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardIconsSet.java new file mode 100644 index 000000000..0c435fe0d --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardIconsSet.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.Log; +import android.util.SparseIntArray; + +import org.kelar.inputmethod.latin.R; + +import java.util.HashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class KeyboardIconsSet { + private static final String TAG = KeyboardIconsSet.class.getSimpleName(); + + public static final String PREFIX_ICON = "!icon/"; + public static final int ICON_UNDEFINED = 0; + private static final int ATTR_UNDEFINED = 0; + + private static final String NAME_UNDEFINED = "undefined"; + public static final String NAME_SHIFT_KEY = "shift_key"; + public static final String NAME_SHIFT_KEY_SHIFTED = "shift_key_shifted"; + public static final String NAME_DELETE_KEY = "delete_key"; + public static final String NAME_SETTINGS_KEY = "settings_key"; + public static final String NAME_SPACE_KEY = "space_key"; + public static final String NAME_SPACE_KEY_FOR_NUMBER_LAYOUT = "space_key_for_number_layout"; + public static final String NAME_ENTER_KEY = "enter_key"; + public static final String NAME_GO_KEY = "go_key"; + public static final String NAME_SEARCH_KEY = "search_key"; + public static final String NAME_SEND_KEY = "send_key"; + public static final String NAME_NEXT_KEY = "next_key"; + public static final String NAME_DONE_KEY = "done_key"; + public static final String NAME_PREVIOUS_KEY = "previous_key"; + public static final String NAME_TAB_KEY = "tab_key"; + public static final String NAME_SHORTCUT_KEY = "shortcut_key"; + public static final String NAME_SHORTCUT_KEY_DISABLED = "shortcut_key_disabled"; + public static final String NAME_LANGUAGE_SWITCH_KEY = "language_switch_key"; + public static final String NAME_ZWNJ_KEY = "zwnj_key"; + public static final String NAME_ZWJ_KEY = "zwj_key"; + public static final String NAME_EMOJI_ACTION_KEY = "emoji_action_key"; + public static final String NAME_EMOJI_NORMAL_KEY = "emoji_normal_key"; + + private static final SparseIntArray ATTR_ID_TO_ICON_ID = new SparseIntArray(); + + // Icon name to icon id map. + private static final HashMap<String, Integer> sNameToIdsMap = new HashMap<>(); + + private static final Object[] NAMES_AND_ATTR_IDS = { + NAME_UNDEFINED, ATTR_UNDEFINED, + NAME_SHIFT_KEY, R.styleable.Keyboard_iconShiftKey, + NAME_DELETE_KEY, R.styleable.Keyboard_iconDeleteKey, + NAME_SETTINGS_KEY, R.styleable.Keyboard_iconSettingsKey, + NAME_SPACE_KEY, R.styleable.Keyboard_iconSpaceKey, + NAME_ENTER_KEY, R.styleable.Keyboard_iconEnterKey, + NAME_GO_KEY, R.styleable.Keyboard_iconGoKey, + NAME_SEARCH_KEY, R.styleable.Keyboard_iconSearchKey, + NAME_SEND_KEY, R.styleable.Keyboard_iconSendKey, + NAME_NEXT_KEY, R.styleable.Keyboard_iconNextKey, + NAME_DONE_KEY, R.styleable.Keyboard_iconDoneKey, + NAME_PREVIOUS_KEY, R.styleable.Keyboard_iconPreviousKey, + NAME_TAB_KEY, R.styleable.Keyboard_iconTabKey, + NAME_SHORTCUT_KEY, R.styleable.Keyboard_iconShortcutKey, + NAME_SPACE_KEY_FOR_NUMBER_LAYOUT, R.styleable.Keyboard_iconSpaceKeyForNumberLayout, + NAME_SHIFT_KEY_SHIFTED, R.styleable.Keyboard_iconShiftKeyShifted, + NAME_SHORTCUT_KEY_DISABLED, R.styleable.Keyboard_iconShortcutKeyDisabled, + NAME_LANGUAGE_SWITCH_KEY, R.styleable.Keyboard_iconLanguageSwitchKey, + NAME_ZWNJ_KEY, R.styleable.Keyboard_iconZwnjKey, + NAME_ZWJ_KEY, R.styleable.Keyboard_iconZwjKey, + NAME_EMOJI_ACTION_KEY, R.styleable.Keyboard_iconEmojiActionKey, + NAME_EMOJI_NORMAL_KEY, R.styleable.Keyboard_iconEmojiNormalKey, + }; + + private static int NUM_ICONS = NAMES_AND_ATTR_IDS.length / 2; + private static final String[] ICON_NAMES = new String[NUM_ICONS]; + private final Drawable[] mIcons = new Drawable[NUM_ICONS]; + private final int[] mIconResourceIds = new int[NUM_ICONS]; + + static { + int iconId = ICON_UNDEFINED; + for (int i = 0; i < NAMES_AND_ATTR_IDS.length; i += 2) { + final String name = (String)NAMES_AND_ATTR_IDS[i]; + final Integer attrId = (Integer)NAMES_AND_ATTR_IDS[i + 1]; + if (attrId != ATTR_UNDEFINED) { + ATTR_ID_TO_ICON_ID.put(attrId, iconId); + } + sNameToIdsMap.put(name, iconId); + ICON_NAMES[iconId] = name; + iconId++; + } + } + + public void loadIcons(final TypedArray keyboardAttrs) { + final int size = ATTR_ID_TO_ICON_ID.size(); + for (int index = 0; index < size; index++) { + final int attrId = ATTR_ID_TO_ICON_ID.keyAt(index); + try { + final Drawable icon = keyboardAttrs.getDrawable(attrId); + setDefaultBounds(icon); + final Integer iconId = ATTR_ID_TO_ICON_ID.get(attrId); + mIcons[iconId] = icon; + mIconResourceIds[iconId] = keyboardAttrs.getResourceId(attrId, 0); + } catch (Resources.NotFoundException e) { + Log.w(TAG, "Drawable resource for icon #" + + keyboardAttrs.getResources().getResourceEntryName(attrId) + + " not found"); + } + } + } + + private static boolean isValidIconId(final int iconId) { + return iconId >= 0 && iconId < ICON_NAMES.length; + } + + @Nonnull + public static String getIconName(final int iconId) { + return isValidIconId(iconId) ? ICON_NAMES[iconId] : "unknown<" + iconId + ">"; + } + + public static int getIconId(final String name) { + Integer iconId = sNameToIdsMap.get(name); + if (iconId != null) { + return iconId; + } + throw new RuntimeException("unknown icon name: " + name); + } + + public int getIconResourceId(final String name) { + final int iconId = getIconId(name); + if (isValidIconId(iconId)) { + return mIconResourceIds[iconId]; + } + throw new RuntimeException("unknown icon name: " + name); + } + + @Nullable + public Drawable getIconDrawable(final int iconId) { + if (isValidIconId(iconId)) { + return mIcons[iconId]; + } + throw new RuntimeException("unknown icon id: " + getIconName(iconId)); + } + + private static void setDefaultBounds(final Drawable icon) { + if (icon != null) { + icon.setBounds(0, 0, icon.getIntrinsicWidth(), icon.getIntrinsicHeight()); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardParams.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardParams.java new file mode 100644 index 000000000..b13b565c0 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardParams.java @@ -0,0 +1,193 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.SparseIntArray; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.latin.common.Constants; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.SortedSet; +import java.util.TreeSet; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class KeyboardParams { + public KeyboardId mId; + public int mThemeId; + + /** Total height and width of the keyboard, including the paddings and keys */ + public int mOccupiedHeight; + public int mOccupiedWidth; + + /** Base height and width of the keyboard used to calculate rows' or keys' heights and + * widths + */ + public int mBaseHeight; + public int mBaseWidth; + + public int mTopPadding; + public int mBottomPadding; + public int mLeftPadding; + public int mRightPadding; + + @Nullable + public KeyVisualAttributes mKeyVisualAttributes; + + public int mDefaultRowHeight; + public int mDefaultKeyWidth; + public int mHorizontalGap; + public int mVerticalGap; + + public int mMoreKeysTemplate; + public int mMaxMoreKeysKeyboardColumn; + + public int GRID_WIDTH; + public int GRID_HEIGHT; + + // Keys are sorted from top-left to bottom-right order. + @Nonnull + public final SortedSet<Key> mSortedKeys = new TreeSet<>(ROW_COLUMN_COMPARATOR); + @Nonnull + public final ArrayList<Key> mShiftKeys = new ArrayList<>(); + @Nonnull + public final ArrayList<Key> mAltCodeKeysWhileTyping = new ArrayList<>(); + @Nonnull + public final KeyboardIconsSet mIconsSet = new KeyboardIconsSet(); + @Nonnull + public final KeyboardTextsSet mTextsSet = new KeyboardTextsSet(); + @Nonnull + public final KeyStylesSet mKeyStyles = new KeyStylesSet(mTextsSet); + + @Nonnull + private final UniqueKeysCache mUniqueKeysCache; + public boolean mAllowRedundantMoreKeys; + + public int mMostCommonKeyHeight = 0; + public int mMostCommonKeyWidth = 0; + + public boolean mProximityCharsCorrectionEnabled; + + @Nonnull + public final TouchPositionCorrection mTouchPositionCorrection = + new TouchPositionCorrection(); + + // Comparator to sort {@link Key}s from top-left to bottom-right order. + private static final Comparator<Key> ROW_COLUMN_COMPARATOR = new Comparator<Key>() { + @Override + public int compare(final Key lhs, final Key rhs) { + if (lhs.getY() < rhs.getY()) return -1; + if (lhs.getY() > rhs.getY()) return 1; + if (lhs.getX() < rhs.getX()) return -1; + if (lhs.getX() > rhs.getX()) return 1; + return 0; + } + }; + + public KeyboardParams() { + this(UniqueKeysCache.NO_CACHE); + } + + public KeyboardParams(@Nonnull final UniqueKeysCache keysCache) { + mUniqueKeysCache = keysCache; + } + + protected void clearKeys() { + mSortedKeys.clear(); + mShiftKeys.clear(); + clearHistogram(); + } + + public void onAddKey(@Nonnull final Key newKey) { + final Key key = mUniqueKeysCache.getUniqueKey(newKey); + final boolean isSpacer = key.isSpacer(); + if (isSpacer && key.getWidth() == 0) { + // Ignore zero width {@link Spacer}. + return; + } + mSortedKeys.add(key); + if (isSpacer) { + return; + } + updateHistogram(key); + if (key.getCode() == Constants.CODE_SHIFT) { + mShiftKeys.add(key); + } + if (key.altCodeWhileTyping()) { + mAltCodeKeysWhileTyping.add(key); + } + } + + public void removeRedundantMoreKeys() { + if (mAllowRedundantMoreKeys) { + return; + } + final MoreKeySpec.LettersOnBaseLayout lettersOnBaseLayout = + new MoreKeySpec.LettersOnBaseLayout(); + for (final Key key : mSortedKeys) { + lettersOnBaseLayout.addLetter(key); + } + final ArrayList<Key> allKeys = new ArrayList<>(mSortedKeys); + mSortedKeys.clear(); + for (final Key key : allKeys) { + final Key filteredKey = Key.removeRedundantMoreKeys(key, lettersOnBaseLayout); + mSortedKeys.add(mUniqueKeysCache.getUniqueKey(filteredKey)); + } + } + + private int mMaxHeightCount = 0; + private int mMaxWidthCount = 0; + private final SparseIntArray mHeightHistogram = new SparseIntArray(); + private final SparseIntArray mWidthHistogram = new SparseIntArray(); + + private void clearHistogram() { + mMostCommonKeyHeight = 0; + mMaxHeightCount = 0; + mHeightHistogram.clear(); + + mMaxWidthCount = 0; + mMostCommonKeyWidth = 0; + mWidthHistogram.clear(); + } + + private static int updateHistogramCounter(final SparseIntArray histogram, final int key) { + final int index = histogram.indexOfKey(key); + final int count = (index >= 0 ? histogram.get(key) : 0) + 1; + histogram.put(key, count); + return count; + } + + private void updateHistogram(final Key key) { + final int height = key.getHeight() + mVerticalGap; + final int heightCount = updateHistogramCounter(mHeightHistogram, height); + if (heightCount > mMaxHeightCount) { + mMaxHeightCount = heightCount; + mMostCommonKeyHeight = height; + } + + final int width = key.getWidth() + mHorizontalGap; + final int widthCount = updateHistogramCounter(mWidthHistogram, width); + if (widthCount > mMaxWidthCount) { + mMaxWidthCount = widthCount; + mMostCommonKeyWidth = width; + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardRow.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardRow.java new file mode 100644 index 000000000..4b3a9df46 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardRow.java @@ -0,0 +1,187 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.util.Xml; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +import org.xmlpull.v1.XmlPullParser; + +import java.util.ArrayDeque; + +/** + * Container for keys in the keyboard. All keys in a row are at the same Y-coordinate. + * Some of the key size defaults can be overridden per row from what the {@link Keyboard} + * defines. + */ +public final class KeyboardRow { + // keyWidth enum constants + private static final int KEYWIDTH_NOT_ENUM = 0; + private static final int KEYWIDTH_FILL_RIGHT = -1; + + private final KeyboardParams mParams; + /** The height of this row. */ + private final int mRowHeight; + + private final ArrayDeque<RowAttributes> mRowAttributesStack = new ArrayDeque<>(); + + // TODO: Add keyActionFlags. + private static class RowAttributes { + /** Default width of a key in this row. */ + public final float mDefaultKeyWidth; + /** Default keyLabelFlags in this row. */ + public final int mDefaultKeyLabelFlags; + /** Default backgroundType for this row */ + public final int mDefaultBackgroundType; + + /** + * Parse and create key attributes. This constructor is used to parse Row tag. + * + * @param keyAttr an attributes array of Row tag. + * @param defaultKeyWidth a default key width. + * @param keyboardWidth the keyboard width that is required to calculate keyWidth attribute. + */ + public RowAttributes(final TypedArray keyAttr, final float defaultKeyWidth, + final int keyboardWidth) { + mDefaultKeyWidth = keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + keyboardWidth, keyboardWidth, defaultKeyWidth); + mDefaultKeyLabelFlags = keyAttr.getInt(R.styleable.Keyboard_Key_keyLabelFlags, 0); + mDefaultBackgroundType = keyAttr.getInt(R.styleable.Keyboard_Key_backgroundType, + Key.BACKGROUND_TYPE_NORMAL); + } + + /** + * Parse and update key attributes using default attributes. This constructor is used + * to parse include tag. + * + * @param keyAttr an attributes array of include tag. + * @param defaultRowAttr default Row attributes. + * @param keyboardWidth the keyboard width that is required to calculate keyWidth attribute. + */ + public RowAttributes(final TypedArray keyAttr, final RowAttributes defaultRowAttr, + final int keyboardWidth) { + mDefaultKeyWidth = keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + keyboardWidth, keyboardWidth, defaultRowAttr.mDefaultKeyWidth); + mDefaultKeyLabelFlags = keyAttr.getInt(R.styleable.Keyboard_Key_keyLabelFlags, 0) + | defaultRowAttr.mDefaultKeyLabelFlags; + mDefaultBackgroundType = keyAttr.getInt(R.styleable.Keyboard_Key_backgroundType, + defaultRowAttr.mDefaultBackgroundType); + } + } + + private final int mCurrentY; + // Will be updated by {@link Key}'s constructor. + private float mCurrentX; + + public KeyboardRow(final Resources res, final KeyboardParams params, + final XmlPullParser parser, final int y) { + mParams = params; + 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(); + final TypedArray keyAttr = res.obtainAttributes(Xml.asAttributeSet(parser), + R.styleable.Keyboard_Key); + mRowAttributesStack.push(new RowAttributes( + keyAttr, params.mDefaultKeyWidth, params.mBaseWidth)); + keyAttr.recycle(); + + mCurrentY = y; + mCurrentX = 0.0f; + } + + public int getRowHeight() { + return mRowHeight; + } + + public void pushRowAttributes(final TypedArray keyAttr) { + final RowAttributes newAttributes = new RowAttributes( + keyAttr, mRowAttributesStack.peek(), mParams.mBaseWidth); + mRowAttributesStack.push(newAttributes); + } + + public void popRowAttributes() { + mRowAttributesStack.pop(); + } + + public float getDefaultKeyWidth() { + return mRowAttributesStack.peek().mDefaultKeyWidth; + } + + public int getDefaultKeyLabelFlags() { + return mRowAttributesStack.peek().mDefaultKeyLabelFlags; + } + + public int getDefaultBackgroundType() { + return mRowAttributesStack.peek().mDefaultBackgroundType; + } + + public void setXPos(final float keyXPos) { + mCurrentX = keyXPos; + } + + public void advanceXPos(final float width) { + mCurrentX += width; + } + + public int getKeyY() { + return mCurrentY; + } + + public float getKeyX(final TypedArray keyAttr) { + if (keyAttr == null || !keyAttr.hasValue(R.styleable.Keyboard_Key_keyXPos)) { + return mCurrentX; + } + final float keyXPos = keyAttr.getFraction(R.styleable.Keyboard_Key_keyXPos, + mParams.mBaseWidth, mParams.mBaseWidth, 0); + if (keyXPos >= 0) { + return keyXPos + mParams.mLeftPadding; + } + // 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); + } + + public float getKeyWidth(final TypedArray keyAttr, final float keyXPos) { + if (keyAttr == null) { + return getDefaultKeyWidth(); + } + final int widthType = ResourceUtils.getEnumValue(keyAttr, + R.styleable.Keyboard_Key_keyWidth, KEYWIDTH_NOT_ENUM); + switch (widthType) { + case KEYWIDTH_FILL_RIGHT: + // 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 keyAttr.getFraction(R.styleable.Keyboard_Key_keyWidth, + mParams.mBaseWidth, mParams.mBaseWidth, getDefaultKeyWidth()); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardState.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardState.java new file mode 100644 index 000000000..4528d49d6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardState.java @@ -0,0 +1,711 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.keyboard.internal; + +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.CapsModeUtils; +import org.kelar.inputmethod.latin.utils.RecapitalizeStatus; + +/** + * Keyboard state machine. + * + * This class contains all keyboard state transition logic. + * + * The input events are {@link #onLoadKeyboard(int, int)}, {@link #onSaveKeyboardState()}, + * {@link #onPressKey(int,boolean,int,int)}, {@link #onReleaseKey(int,boolean,int,int)}, + * {@link #onEvent(Event,int,int)}, {@link #onFinishSlidingInput(int,int)}, + * {@link #onUpdateShiftState(int,int)}, {@link #onResetKeyboardStateToAlphabet(int,int)}. + * + * The actions are {@link SwitchActions}'s methods. + */ +public final class KeyboardState { + private static final String TAG = KeyboardState.class.getSimpleName(); + private static final boolean DEBUG_EVENT = false; + private static final boolean DEBUG_INTERNAL_ACTION = false; + + public interface SwitchActions { + public static final boolean DEBUG_ACTION = false; + + public void setAlphabetKeyboard(); + public void setAlphabetManualShiftedKeyboard(); + public void setAlphabetAutomaticShiftedKeyboard(); + public void setAlphabetShiftLockedKeyboard(); + public void setAlphabetShiftLockShiftedKeyboard(); + public void setEmojiKeyboard(); + public void setSymbolsKeyboard(); + public void setSymbolsShiftedKeyboard(); + + /** + * Request to call back {@link KeyboardState#onUpdateShiftState(int, int)}. + */ + public void requestUpdatingShiftState(final int autoCapsFlags, final int recapitalizeMode); + + public static final boolean DEBUG_TIMER_ACTION = false; + + public void startDoubleTapShiftKeyTimer(); + public boolean isInDoubleTapShiftKeyTimeout(); + public void cancelDoubleTapShiftKeyTimer(); + } + + private final SwitchActions mSwitchActions; + + private ShiftKeyState mShiftKeyState = new ShiftKeyState("Shift"); + private ModifierKeyState mSymbolKeyState = new ModifierKeyState("Symbol"); + + // TODO: Merge {@link #mSwitchState}, {@link #mIsAlphabetMode}, {@link #mAlphabetShiftState}, + // {@link #mIsSymbolShifted}, {@link #mPrevMainKeyboardWasShiftLocked}, and + // {@link #mPrevSymbolsKeyboardWasShifted} into single state variable. + private static final int SWITCH_STATE_ALPHA = 0; + private static final int SWITCH_STATE_SYMBOL_BEGIN = 1; + private static final int SWITCH_STATE_SYMBOL = 2; + private static final int SWITCH_STATE_MOMENTARY_ALPHA_AND_SYMBOL = 3; + private static final int SWITCH_STATE_MOMENTARY_SYMBOL_AND_MORE = 4; + private static final int SWITCH_STATE_MOMENTARY_ALPHA_SHIFT = 5; + private int mSwitchState = SWITCH_STATE_ALPHA; + + // TODO: Consolidate these two mode booleans into one integer to distinguish between alphabet, + // symbols, and emoji mode. + private boolean mIsAlphabetMode; + private boolean mIsEmojiMode; + private AlphabetShiftState mAlphabetShiftState = new AlphabetShiftState(); + private boolean mIsSymbolShifted; + private boolean mPrevMainKeyboardWasShiftLocked; + private boolean mPrevSymbolsKeyboardWasShifted; + private int mRecapitalizeMode; + + // For handling double tap. + private boolean mIsInAlphabetUnshiftedFromShifted; + private boolean mIsInDoubleTapShiftKey; + + private final SavedKeyboardState mSavedKeyboardState = new SavedKeyboardState(); + + static final class SavedKeyboardState { + public boolean mIsValid; + public boolean mIsAlphabetMode; + public boolean mIsAlphabetShiftLocked; + public boolean mIsEmojiMode; + public int mShiftMode; + + @Override + public String toString() { + if (!mIsValid) { + return "INVALID"; + } + if (mIsAlphabetMode) { + return mIsAlphabetShiftLocked ? "ALPHABET_SHIFT_LOCKED" + : "ALPHABET_" + shiftModeToString(mShiftMode); + } + if (mIsEmojiMode) { + return "EMOJI"; + } + return "SYMBOLS_" + shiftModeToString(mShiftMode); + } + } + + public KeyboardState(final SwitchActions switchActions) { + mSwitchActions = switchActions; + mRecapitalizeMode = RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + } + + public void onLoadKeyboard(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onLoadKeyboard: " + stateToString(autoCapsFlags, recapitalizeMode)); + } + // Reset alphabet shift state. + mAlphabetShiftState.setShiftLocked(false); + mPrevMainKeyboardWasShiftLocked = false; + mPrevSymbolsKeyboardWasShifted = false; + mShiftKeyState.onRelease(); + mSymbolKeyState.onRelease(); + if (mSavedKeyboardState.mIsValid) { + onRestoreKeyboardState(autoCapsFlags, recapitalizeMode); + mSavedKeyboardState.mIsValid = false; + } else { + // Reset keyboard to alphabet mode. + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + } + } + + // Constants for {@link SavedKeyboardState#mShiftMode} and {@link #setShifted(int)}. + private static final int UNSHIFT = 0; + private static final int MANUAL_SHIFT = 1; + private static final int AUTOMATIC_SHIFT = 2; + private static final int SHIFT_LOCK_SHIFTED = 3; + + public void onSaveKeyboardState() { + final SavedKeyboardState state = mSavedKeyboardState; + state.mIsAlphabetMode = mIsAlphabetMode; + state.mIsEmojiMode = mIsEmojiMode; + if (mIsAlphabetMode) { + state.mIsAlphabetShiftLocked = mAlphabetShiftState.isShiftLocked(); + state.mShiftMode = mAlphabetShiftState.isAutomaticShifted() ? AUTOMATIC_SHIFT + : (mAlphabetShiftState.isShiftedOrShiftLocked() ? MANUAL_SHIFT : UNSHIFT); + } else { + state.mIsAlphabetShiftLocked = mPrevMainKeyboardWasShiftLocked; + state.mShiftMode = mIsSymbolShifted ? MANUAL_SHIFT : UNSHIFT; + } + state.mIsValid = true; + if (DEBUG_EVENT) { + Log.d(TAG, "onSaveKeyboardState: saved=" + state + " " + this); + } + } + + private void onRestoreKeyboardState(final int autoCapsFlags, final int recapitalizeMode) { + final SavedKeyboardState state = mSavedKeyboardState; + if (DEBUG_EVENT) { + Log.d(TAG, "onRestoreKeyboardState: saved=" + state + + " " + stateToString(autoCapsFlags, recapitalizeMode)); + } + mPrevMainKeyboardWasShiftLocked = state.mIsAlphabetShiftLocked; + if (state.mIsAlphabetMode) { + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + setShiftLocked(state.mIsAlphabetShiftLocked); + if (!state.mIsAlphabetShiftLocked) { + setShifted(state.mShiftMode); + } + return; + } + if (state.mIsEmojiMode) { + setEmojiKeyboard(); + return; + } + // Symbol mode + if (state.mShiftMode == MANUAL_SHIFT) { + setSymbolsShiftedKeyboard(); + } else { + setSymbolsKeyboard(); + } + } + + private void setShifted(final int shiftMode) { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setShifted: shiftMode=" + shiftModeToString(shiftMode) + " " + this); + } + if (!mIsAlphabetMode) return; + final int prevShiftMode; + if (mAlphabetShiftState.isAutomaticShifted()) { + prevShiftMode = AUTOMATIC_SHIFT; + } else if (mAlphabetShiftState.isManualShifted()) { + prevShiftMode = MANUAL_SHIFT; + } else { + prevShiftMode = UNSHIFT; + } + switch (shiftMode) { + case AUTOMATIC_SHIFT: + mAlphabetShiftState.setAutomaticShifted(); + if (shiftMode != prevShiftMode) { + mSwitchActions.setAlphabetAutomaticShiftedKeyboard(); + } + break; + case MANUAL_SHIFT: + mAlphabetShiftState.setShifted(true); + if (shiftMode != prevShiftMode) { + mSwitchActions.setAlphabetManualShiftedKeyboard(); + } + break; + case UNSHIFT: + mAlphabetShiftState.setShifted(false); + if (shiftMode != prevShiftMode) { + mSwitchActions.setAlphabetKeyboard(); + } + break; + case SHIFT_LOCK_SHIFTED: + mAlphabetShiftState.setShifted(true); + mSwitchActions.setAlphabetShiftLockShiftedKeyboard(); + break; + } + } + + private void setShiftLocked(final boolean shiftLocked) { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setShiftLocked: shiftLocked=" + shiftLocked + " " + this); + } + if (!mIsAlphabetMode) return; + if (shiftLocked && (!mAlphabetShiftState.isShiftLocked() + || mAlphabetShiftState.isShiftLockShifted())) { + mSwitchActions.setAlphabetShiftLockedKeyboard(); + } + if (!shiftLocked && mAlphabetShiftState.isShiftLocked()) { + mSwitchActions.setAlphabetKeyboard(); + } + mAlphabetShiftState.setShiftLocked(shiftLocked); + } + + private void toggleAlphabetAndSymbols(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "toggleAlphabetAndSymbols: " + + stateToString(autoCapsFlags, recapitalizeMode)); + } + if (mIsAlphabetMode) { + mPrevMainKeyboardWasShiftLocked = mAlphabetShiftState.isShiftLocked(); + if (mPrevSymbolsKeyboardWasShifted) { + setSymbolsShiftedKeyboard(); + } else { + setSymbolsKeyboard(); + } + mPrevSymbolsKeyboardWasShifted = false; + } else { + mPrevSymbolsKeyboardWasShifted = mIsSymbolShifted; + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + if (mPrevMainKeyboardWasShiftLocked) { + setShiftLocked(true); + } + mPrevMainKeyboardWasShiftLocked = false; + } + } + + // TODO: Remove this method. Come up with a more comprehensive way to reset the keyboard layout + // when a keyboard layout set doesn't get reloaded in LatinIME.onStartInputViewInternal(). + private void resetKeyboardStateToAlphabet(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "resetKeyboardStateToAlphabet: " + + stateToString(autoCapsFlags, recapitalizeMode)); + } + if (mIsAlphabetMode) return; + + mPrevSymbolsKeyboardWasShifted = mIsSymbolShifted; + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + if (mPrevMainKeyboardWasShiftLocked) { + setShiftLocked(true); + } + mPrevMainKeyboardWasShiftLocked = false; + } + + private void toggleShiftInSymbols() { + if (mIsSymbolShifted) { + setSymbolsKeyboard(); + } else { + setSymbolsShiftedKeyboard(); + } + } + + private void setAlphabetKeyboard(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setAlphabetKeyboard: " + stateToString(autoCapsFlags, recapitalizeMode)); + } + + mSwitchActions.setAlphabetKeyboard(); + mIsAlphabetMode = true; + mIsEmojiMode = false; + mIsSymbolShifted = false; + mRecapitalizeMode = RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + mSwitchState = SWITCH_STATE_ALPHA; + mSwitchActions.requestUpdatingShiftState(autoCapsFlags, recapitalizeMode); + } + + private void setSymbolsKeyboard() { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setSymbolsKeyboard"); + } + mSwitchActions.setSymbolsKeyboard(); + mIsAlphabetMode = false; + mIsSymbolShifted = false; + mRecapitalizeMode = RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + // Reset alphabet shift state. + mAlphabetShiftState.setShiftLocked(false); + mSwitchState = SWITCH_STATE_SYMBOL_BEGIN; + } + + private void setSymbolsShiftedKeyboard() { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setSymbolsShiftedKeyboard"); + } + mSwitchActions.setSymbolsShiftedKeyboard(); + mIsAlphabetMode = false; + mIsSymbolShifted = true; + mRecapitalizeMode = RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + // Reset alphabet shift state. + mAlphabetShiftState.setShiftLocked(false); + mSwitchState = SWITCH_STATE_SYMBOL_BEGIN; + } + + private void setEmojiKeyboard() { + if (DEBUG_INTERNAL_ACTION) { + Log.d(TAG, "setEmojiKeyboard"); + } + mIsAlphabetMode = false; + mIsEmojiMode = true; + mRecapitalizeMode = RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + // Remember caps lock mode and reset alphabet shift state. + mPrevMainKeyboardWasShiftLocked = mAlphabetShiftState.isShiftLocked(); + mAlphabetShiftState.setShiftLocked(false); + mSwitchActions.setEmojiKeyboard(); + } + + public void onPressKey(final int code, final boolean isSinglePointer, final int autoCapsFlags, + final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onPressKey: code=" + Constants.printableCode(code) + + " single=" + isSinglePointer + + " " + stateToString(autoCapsFlags, recapitalizeMode)); + } + if (code != Constants.CODE_SHIFT) { + // Because the double tap shift key timer is to detect two consecutive shift key press, + // it should be canceled when a non-shift key is pressed. + mSwitchActions.cancelDoubleTapShiftKeyTimer(); + } + if (code == Constants.CODE_SHIFT) { + onPressShift(); + } else if (code == Constants.CODE_CAPSLOCK) { + // Nothing to do here. See {@link #onReleaseKey(int,boolean)}. + } else if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) { + onPressSymbol(autoCapsFlags, recapitalizeMode); + } else { + mShiftKeyState.onOtherKeyPressed(); + mSymbolKeyState.onOtherKeyPressed(); + // It is required to reset the auto caps state when all of the following conditions + // are met: + // 1) two or more fingers are in action + // 2) in alphabet layout + // 3) not in all characters caps mode + // As for #3, please note that it's required to check even when the auto caps mode is + // off because, for example, we may be in the #1 state within the manual temporary + // shifted mode. + if (!isSinglePointer && mIsAlphabetMode + && autoCapsFlags != TextUtils.CAP_MODE_CHARACTERS) { + final boolean needsToResetAutoCaps = mAlphabetShiftState.isAutomaticShifted() + || (mAlphabetShiftState.isManualShifted() && mShiftKeyState.isReleasing()); + if (needsToResetAutoCaps) { + mSwitchActions.setAlphabetKeyboard(); + } + } + } + } + + public void onReleaseKey(final int code, final boolean withSliding, final int autoCapsFlags, + final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onReleaseKey: code=" + Constants.printableCode(code) + + " sliding=" + withSliding + + " " + stateToString(autoCapsFlags, recapitalizeMode)); + } + if (code == Constants.CODE_SHIFT) { + onReleaseShift(withSliding, autoCapsFlags, recapitalizeMode); + } else if (code == Constants.CODE_CAPSLOCK) { + setShiftLocked(!mAlphabetShiftState.isShiftLocked()); + } else if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) { + onReleaseSymbol(withSliding, autoCapsFlags, recapitalizeMode); + } + } + + private void onPressSymbol(final int autoCapsFlags, + final int recapitalizeMode) { + toggleAlphabetAndSymbols(autoCapsFlags, recapitalizeMode); + mSymbolKeyState.onPress(); + mSwitchState = SWITCH_STATE_MOMENTARY_ALPHA_AND_SYMBOL; + } + + private void onReleaseSymbol(final boolean withSliding, final int autoCapsFlags, + final int recapitalizeMode) { + if (mSymbolKeyState.isChording()) { + // Switch back to the previous keyboard mode if the user chords the mode change key and + // another key, then releases the mode change key. + toggleAlphabetAndSymbols(autoCapsFlags, recapitalizeMode); + } else if (!withSliding) { + // If the mode change key is being released without sliding, we should forget the + // previous symbols keyboard shift state and simply switch back to symbols layout + // (never symbols shifted) next time the mode gets changed to symbols layout. + mPrevSymbolsKeyboardWasShifted = false; + } + mSymbolKeyState.onRelease(); + } + + public void onUpdateShiftState(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onUpdateShiftState: " + stateToString(autoCapsFlags, recapitalizeMode)); + } + mRecapitalizeMode = recapitalizeMode; + updateAlphabetShiftState(autoCapsFlags, recapitalizeMode); + } + + // TODO: Remove this method. Come up with a more comprehensive way to reset the keyboard layout + // when a keyboard layout set doesn't get reloaded in LatinIME.onStartInputViewInternal(). + public void onResetKeyboardStateToAlphabet(final int autoCapsFlags, + final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onResetKeyboardStateToAlphabet: " + + stateToString(autoCapsFlags, recapitalizeMode)); + } + resetKeyboardStateToAlphabet(autoCapsFlags, recapitalizeMode); + } + + private void updateShiftStateForRecapitalize(final int recapitalizeMode) { + switch (recapitalizeMode) { + case RecapitalizeStatus.CAPS_MODE_ALL_UPPER: + setShifted(SHIFT_LOCK_SHIFTED); + break; + case RecapitalizeStatus.CAPS_MODE_FIRST_WORD_UPPER: + setShifted(AUTOMATIC_SHIFT); + break; + case RecapitalizeStatus.CAPS_MODE_ALL_LOWER: + case RecapitalizeStatus.CAPS_MODE_ORIGINAL_MIXED_CASE: + default: + setShifted(UNSHIFT); + } + } + + private void updateAlphabetShiftState(final int autoCapsFlags, final int recapitalizeMode) { + if (!mIsAlphabetMode) return; + if (RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE != recapitalizeMode) { + // We are recapitalizing. Match the keyboard to the current recapitalize state. + updateShiftStateForRecapitalize(recapitalizeMode); + return; + } + if (!mShiftKeyState.isReleasing()) { + // Ignore update shift state event while the shift key is being pressed (including + // chording). + return; + } + if (!mAlphabetShiftState.isShiftLocked() && !mShiftKeyState.isIgnoring()) { + if (mShiftKeyState.isReleasing() && autoCapsFlags != Constants.TextUtils.CAP_MODE_OFF) { + // Only when shift key is releasing, automatic temporary upper case will be set. + setShifted(AUTOMATIC_SHIFT); + } else { + setShifted(mShiftKeyState.isChording() ? MANUAL_SHIFT : UNSHIFT); + } + } + } + + private void onPressShift() { + // If we are recapitalizing, we don't do any of the normal processing, including + // importantly the double tap timer. + if (RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE != mRecapitalizeMode) { + return; + } + if (mIsAlphabetMode) { + mIsInDoubleTapShiftKey = mSwitchActions.isInDoubleTapShiftKeyTimeout(); + if (!mIsInDoubleTapShiftKey) { + // This is first tap. + mSwitchActions.startDoubleTapShiftKeyTimer(); + } + if (mIsInDoubleTapShiftKey) { + if (mAlphabetShiftState.isManualShifted() || mIsInAlphabetUnshiftedFromShifted) { + // Shift key has been double tapped while in manual shifted or automatic + // shifted state. + setShiftLocked(true); + } else { + // Shift key has been double tapped while in normal state. This is the second + // tap to disable shift locked state, so just ignore this. + } + } else { + if (mAlphabetShiftState.isShiftLocked()) { + // Shift key is pressed while shift locked state, we will treat this state as + // shift lock shifted state and mark as if shift key pressed while normal + // state. + setShifted(SHIFT_LOCK_SHIFTED); + mShiftKeyState.onPress(); + } else if (mAlphabetShiftState.isAutomaticShifted()) { + // Shift key is pressed while automatic shifted, we have to move to manual + // shifted. + setShifted(MANUAL_SHIFT); + mShiftKeyState.onPress(); + } else if (mAlphabetShiftState.isShiftedOrShiftLocked()) { + // In manual shifted state, we just record shift key has been pressing while + // shifted state. + mShiftKeyState.onPressOnShifted(); + } else { + // In base layout, chording or manual shifted mode is started. + setShifted(MANUAL_SHIFT); + mShiftKeyState.onPress(); + } + } + } else { + // In symbol mode, just toggle symbol and symbol more keyboard. + toggleShiftInSymbols(); + mSwitchState = SWITCH_STATE_MOMENTARY_SYMBOL_AND_MORE; + mShiftKeyState.onPress(); + } + } + + private void onReleaseShift(final boolean withSliding, final int autoCapsFlags, + final int recapitalizeMode) { + if (RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE != mRecapitalizeMode) { + // We are recapitalizing. We should match the keyboard state to the recapitalize + // state in priority. + updateShiftStateForRecapitalize(mRecapitalizeMode); + } else if (mIsAlphabetMode) { + final boolean isShiftLocked = mAlphabetShiftState.isShiftLocked(); + mIsInAlphabetUnshiftedFromShifted = false; + if (mIsInDoubleTapShiftKey) { + // Double tap shift key has been handled in {@link #onPressShift}, so that just + // ignore this release shift key here. + mIsInDoubleTapShiftKey = false; + } else if (mShiftKeyState.isChording()) { + if (mAlphabetShiftState.isShiftLockShifted()) { + // After chording input while shift locked state. + setShiftLocked(true); + } else { + // After chording input while normal state. + setShifted(UNSHIFT); + } + // After chording input, automatic shift state may have been changed depending on + // what characters were input. + mShiftKeyState.onRelease(); + mSwitchActions.requestUpdatingShiftState(autoCapsFlags, recapitalizeMode); + return; + } else if (mAlphabetShiftState.isShiftLockShifted() && withSliding) { + // In shift locked state, shift has been pressed and slid out to other key. + setShiftLocked(true); + } else if (mAlphabetShiftState.isManualShifted() && withSliding) { + // Shift has been pressed and slid out to other key. + mSwitchState = SWITCH_STATE_MOMENTARY_ALPHA_SHIFT; + } else if (isShiftLocked && !mAlphabetShiftState.isShiftLockShifted() + && (mShiftKeyState.isPressing() || mShiftKeyState.isPressingOnShifted()) + && !withSliding) { + // Shift has been long pressed, ignore this release. + } else if (isShiftLocked && !mShiftKeyState.isIgnoring() && !withSliding) { + // Shift has been pressed without chording while shift locked state. + setShiftLocked(false); + } else if (mAlphabetShiftState.isShiftedOrShiftLocked() + && mShiftKeyState.isPressingOnShifted() && !withSliding) { + // Shift has been pressed without chording while shifted state. + setShifted(UNSHIFT); + mIsInAlphabetUnshiftedFromShifted = true; + } else if (mAlphabetShiftState.isManualShiftedFromAutomaticShifted() + && mShiftKeyState.isPressing() && !withSliding) { + // Shift has been pressed without chording while manual shifted transited from + // automatic shifted + setShifted(UNSHIFT); + mIsInAlphabetUnshiftedFromShifted = true; + } + } else { + // In symbol mode, switch back to the previous keyboard mode if the user chords the + // shift key and another key, then releases the shift key. + if (mShiftKeyState.isChording()) { + toggleShiftInSymbols(); + } + } + mShiftKeyState.onRelease(); + } + + public void onFinishSlidingInput(final int autoCapsFlags, final int recapitalizeMode) { + if (DEBUG_EVENT) { + Log.d(TAG, "onFinishSlidingInput: " + stateToString(autoCapsFlags, recapitalizeMode)); + } + // Switch back to the previous keyboard mode if the user cancels sliding input. + switch (mSwitchState) { + case SWITCH_STATE_MOMENTARY_ALPHA_AND_SYMBOL: + toggleAlphabetAndSymbols(autoCapsFlags, recapitalizeMode); + break; + case SWITCH_STATE_MOMENTARY_SYMBOL_AND_MORE: + toggleShiftInSymbols(); + break; + case SWITCH_STATE_MOMENTARY_ALPHA_SHIFT: + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + break; + } + } + + private static boolean isSpaceOrEnter(final int c) { + return c == Constants.CODE_SPACE || c == Constants.CODE_ENTER; + } + + public void onEvent(final Event event, final int autoCapsFlags, final int recapitalizeMode) { + final int code = event.isFunctionalKeyEvent() ? event.mKeyCode : event.mCodePoint; + if (DEBUG_EVENT) { + Log.d(TAG, "onEvent: code=" + Constants.printableCode(code) + + " " + stateToString(autoCapsFlags, recapitalizeMode)); + } + + switch (mSwitchState) { + case SWITCH_STATE_MOMENTARY_ALPHA_AND_SYMBOL: + if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) { + // Detected only the mode change key has been pressed, and then released. + if (mIsAlphabetMode) { + mSwitchState = SWITCH_STATE_ALPHA; + } else { + mSwitchState = SWITCH_STATE_SYMBOL_BEGIN; + } + } + break; + case SWITCH_STATE_MOMENTARY_SYMBOL_AND_MORE: + if (code == Constants.CODE_SHIFT) { + // Detected only the shift key has been pressed on symbol layout, and then + // released. + mSwitchState = SWITCH_STATE_SYMBOL_BEGIN; + } + break; + case SWITCH_STATE_SYMBOL_BEGIN: + if (mIsEmojiMode) { + // When in the Emoji keyboard, we don't want to switch back to the main layout even + // after the user hits an emoji letter followed by an enter or a space. + break; + } + if (!isSpaceOrEnter(code) && (Constants.isLetterCode(code) + || code == Constants.CODE_OUTPUT_TEXT)) { + mSwitchState = SWITCH_STATE_SYMBOL; + } + break; + case SWITCH_STATE_SYMBOL: + // Switch back to alpha keyboard mode if user types one or more non-space/enter + // characters followed by a space/enter. + if (isSpaceOrEnter(code)) { + toggleAlphabetAndSymbols(autoCapsFlags, recapitalizeMode); + mPrevSymbolsKeyboardWasShifted = false; + } + break; + } + + // If the code is a letter, update keyboard shift state. + if (Constants.isLetterCode(code)) { + updateAlphabetShiftState(autoCapsFlags, recapitalizeMode); + } else if (code == Constants.CODE_EMOJI) { + setEmojiKeyboard(); + } else if (code == Constants.CODE_ALPHA_FROM_EMOJI) { + setAlphabetKeyboard(autoCapsFlags, recapitalizeMode); + } + } + + static String shiftModeToString(final int shiftMode) { + switch (shiftMode) { + case UNSHIFT: return "UNSHIFT"; + case MANUAL_SHIFT: return "MANUAL"; + case AUTOMATIC_SHIFT: return "AUTOMATIC"; + default: return null; + } + } + + private static String switchStateToString(final int switchState) { + switch (switchState) { + case SWITCH_STATE_ALPHA: return "ALPHA"; + case SWITCH_STATE_SYMBOL_BEGIN: return "SYMBOL-BEGIN"; + case SWITCH_STATE_SYMBOL: return "SYMBOL"; + case SWITCH_STATE_MOMENTARY_ALPHA_AND_SYMBOL: return "MOMENTARY-ALPHA-SYMBOL"; + case SWITCH_STATE_MOMENTARY_SYMBOL_AND_MORE: return "MOMENTARY-SYMBOL-MORE"; + case SWITCH_STATE_MOMENTARY_ALPHA_SHIFT: return "MOMENTARY-ALPHA_SHIFT"; + default: return null; + } + } + + @Override + public String toString() { + return "[keyboard=" + (mIsAlphabetMode ? mAlphabetShiftState.toString() + : (mIsSymbolShifted ? "SYMBOLS_SHIFTED" : "SYMBOLS")) + + " shift=" + mShiftKeyState + + " symbol=" + mSymbolKeyState + + " switch=" + switchStateToString(mSwitchState) + "]"; + } + + private String stateToString(final int autoCapsFlags, final int recapitalizeMode) { + return this + " autoCapsFlags=" + CapsModeUtils.flagsToString(autoCapsFlags) + + " recapitalizeMode=" + RecapitalizeStatus.modeToString(recapitalizeMode); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsSet.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsSet.java new file mode 100644 index 000000000..04b484edd --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsSet.java @@ -0,0 +1,151 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.Context; +import android.content.res.Resources; +import android.text.TextUtils; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.RunInLocale; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.util.Locale; + +// TODO: Make this an immutable class. +public final class KeyboardTextsSet { + public static final String PREFIX_TEXT = "!text/"; + private static final String PREFIX_RESOURCE = "!string/"; + public static final String SWITCH_TO_ALPHA_KEY_LABEL = "keylabel_to_alpha"; + + private static final char BACKSLASH = Constants.CODE_BACKSLASH; + private static final int MAX_REFERENCE_INDIRECTION = 10; + + private Resources mResources; + private Locale mResourceLocale; + private String mResourcePackageName; + private String[] mTextsTable; + + public void setLocale(final Locale locale, final Context context) { + final Resources res = context.getResources(); + // Null means the current system locale. + final String resourcePackageName = res.getResourcePackageName( + context.getApplicationInfo().labelRes); + setLocale(locale, res, resourcePackageName); + } + + @UsedForTesting + public void setLocale(final Locale locale, final Resources res, + final String resourcePackageName) { + mResources = res; + // Null means the current system locale. + mResourceLocale = SubtypeLocaleUtils.NO_LANGUAGE.equals(locale.toString()) ? null : locale; + mResourcePackageName = resourcePackageName; + mTextsTable = KeyboardTextsTable.getTextsTable(locale); + } + + public String getText(final String name) { + return KeyboardTextsTable.getText(name, mTextsTable); + } + + private static int searchTextNameEnd(final String text, final int start) { + final int size = text.length(); + for (int pos = start; pos < size; pos++) { + final char c = text.charAt(pos); + // Label name should be consisted of [a-zA-Z_0-9]. + if ((c >= 'a' && c <= 'z') || c == '_' || (c >= '0' && c <= '9')) { + continue; + } + return pos; + } + return size; + } + + // TODO: Resolve text reference when creating {@link KeyboardTextsTable} class. + public String resolveTextReference(final String rawText) { + if (TextUtils.isEmpty(rawText)) { + return null; + } + int level = 0; + String text = rawText; + StringBuilder sb; + do { + level++; + if (level >= MAX_REFERENCE_INDIRECTION) { + throw new RuntimeException("Too many " + PREFIX_TEXT + " or " + PREFIX_RESOURCE + + " reference indirection: " + text); + } + + final int prefixLength = PREFIX_TEXT.length(); + final int size = text.length(); + if (size < prefixLength) { + break; + } + + sb = null; + for (int pos = 0; pos < size; pos++) { + final char c = text.charAt(pos); + if (text.startsWith(PREFIX_TEXT, pos)) { + if (sb == null) { + sb = new StringBuilder(text.substring(0, pos)); + } + pos = expandReference(text, pos, PREFIX_TEXT, sb); + } else if (text.startsWith(PREFIX_RESOURCE, pos)) { + if (sb == null) { + sb = new StringBuilder(text.substring(0, pos)); + } + pos = expandReference(text, pos, PREFIX_RESOURCE, sb); + } else if (c == BACKSLASH) { + if (sb != null) { + // Append both escape character and escaped character. + sb.append(text.substring(pos, Math.min(pos + 2, size))); + } + pos++; + } else if (sb != null) { + sb.append(c); + } + } + + if (sb != null) { + text = sb.toString(); + } + } while (sb != null); + return TextUtils.isEmpty(text) ? null : text; + } + + private int expandReference(final String text, final int pos, final String prefix, + final StringBuilder sb) { + final int prefixLength = prefix.length(); + final int end = searchTextNameEnd(text, pos + prefixLength); + final String name = text.substring(pos + prefixLength, end); + if (prefix.equals(PREFIX_TEXT)) { + sb.append(getText(name)); + } else { // PREFIX_RESOURCE + final String resourcePackageName = mResourcePackageName; + final RunInLocale<String> getTextJob = new RunInLocale<String>() { + @Override + protected String job(final Resources res) { + final int resId = res.getIdentifier(name, "string", resourcePackageName); + return res.getString(resId); + } + }; + sb.append(getTextJob.runInLocale(mResources, mResourceLocale)); + } + return end - 1; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsTable.java b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsTable.java new file mode 100644 index 000000000..810ec36f0 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsTable.java @@ -0,0 +1,4198 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import java.util.HashMap; +import java.util.Locale; + +/** + * !!!!! DO NOT EDIT THIS FILE !!!!! + * + * This file is generated by tools/make-keyboard-text. The base template file is + * tools/make-keyboard-text/res/src/org.kelar.inputmethod/keyboard/internal/ + * KeyboardTextsTable.tmpl + * + * This file must be updated when any text resources in keyboard layout files have been changed. + * These text resources are referred as "!text/<resource_name>" in keyboard XML definitions, + * and should be defined in + * tools/make-keyboard-text/res/values-<locale>/donottranslate-more-keys.xml + * + * To update this file, please run the following commands. + * $ cd $ANDROID_BUILD_TOP + * $ mmm packages/inputmethods/LatinIME/tools/make-keyboard-text + * $ make-keyboard-text -java packages/inputmethods/LatinIME/java + * + * The updated source file will be generated to the following path (this file). + * packages/inputmethods/LatinIME/java/src/org.kelar.inputmethod/keyboard/internal/ + * KeyboardTextsTable.java + */ +public final class KeyboardTextsTable { + // Name to index map. + private static final HashMap<String, Integer> sNameToIndexesMap = new HashMap<>(); + // Locale to texts table map. + private static final HashMap<String, String[]> sLocaleToTextsTableMap = new HashMap<>(); + // TODO: Remove this variable after debugging. + // Texts table to locale maps. + private static final HashMap<String[], String> sTextsTableToLocaleMap = new HashMap<>(); + + public static String getText(final String name, final String[] textsTable) { + final Integer indexObj = sNameToIndexesMap.get(name); + if (indexObj == null) { + throw new RuntimeException("Unknown text name=" + name + " locale=" + + sTextsTableToLocaleMap.get(textsTable)); + } + final int index = indexObj; + final String text = (index < textsTable.length) ? textsTable[index] : null; + if (text != null) { + return text; + } + // Validity check. + if (index >= 0 && index < TEXTS_DEFAULT.length) { + return TEXTS_DEFAULT[index]; + } + // Throw exception for debugging purpose. + throw new RuntimeException("Illegal index=" + index + " for name=" + name + + " locale=" + sTextsTableToLocaleMap.get(textsTable)); + } + + public static String[] getTextsTable(final Locale locale) { + final String localeKey = locale.toString(); + if (sLocaleToTextsTableMap.containsKey(localeKey)) { + return sLocaleToTextsTableMap.get(localeKey); + } + final String languageKey = locale.getLanguage(); + if (sLocaleToTextsTableMap.containsKey(languageKey)) { + return sLocaleToTextsTableMap.get(languageKey); + } + return TEXTS_DEFAULT; + } + + private static final String[] NAMES = { + // /* index:histogram */ "name", + /* 0:33 */ "morekeys_a", + /* 1:33 */ "morekeys_o", + /* 2:32 */ "morekeys_e", + /* 3:31 */ "morekeys_u", + /* 4:31 */ "keylabel_to_alpha", + /* 5:30 */ "morekeys_i", + /* 6:25 */ "morekeys_n", + /* 7:25 */ "morekeys_c", + /* 8:23 */ "double_quotes", + /* 9:22 */ "morekeys_s", + /* 10:22 */ "single_quotes", + /* 11:19 */ "keyspec_currency", + /* 12:17 */ "morekeys_y", + /* 13:16 */ "morekeys_z", + /* 14:14 */ "morekeys_d", + /* 15:10 */ "morekeys_t", + /* 16:10 */ "morekeys_l", + /* 17:10 */ "morekeys_g", + /* 18: 9 */ "single_angle_quotes", + /* 19: 9 */ "double_angle_quotes", + /* 20: 8 */ "morekeys_r", + /* 21: 6 */ "morekeys_k", + /* 22: 6 */ "morekeys_cyrillic_ie", + /* 23: 5 */ "keyspec_nordic_row1_11", + /* 24: 5 */ "keyspec_nordic_row2_10", + /* 25: 5 */ "keyspec_nordic_row2_11", + /* 26: 5 */ "morekeys_nordic_row2_10", + /* 27: 5 */ "keyspec_east_slavic_row1_9", + /* 28: 5 */ "keyspec_east_slavic_row2_2", + /* 29: 5 */ "keyspec_east_slavic_row2_11", + /* 30: 5 */ "keyspec_east_slavic_row3_5", + /* 31: 5 */ "morekeys_cyrillic_soft_sign", + /* 32: 5 */ "keyspec_symbols_1", + /* 33: 5 */ "keyspec_symbols_2", + /* 34: 5 */ "keyspec_symbols_3", + /* 35: 5 */ "keyspec_symbols_4", + /* 36: 5 */ "keyspec_symbols_5", + /* 37: 5 */ "keyspec_symbols_6", + /* 38: 5 */ "keyspec_symbols_7", + /* 39: 5 */ "keyspec_symbols_8", + /* 40: 5 */ "keyspec_symbols_9", + /* 41: 5 */ "keyspec_symbols_0", + /* 42: 5 */ "keylabel_to_symbol", + /* 43: 5 */ "additional_morekeys_symbols_1", + /* 44: 5 */ "additional_morekeys_symbols_2", + /* 45: 5 */ "additional_morekeys_symbols_3", + /* 46: 5 */ "additional_morekeys_symbols_4", + /* 47: 5 */ "additional_morekeys_symbols_5", + /* 48: 5 */ "additional_morekeys_symbols_6", + /* 49: 5 */ "additional_morekeys_symbols_7", + /* 50: 5 */ "additional_morekeys_symbols_8", + /* 51: 5 */ "additional_morekeys_symbols_9", + /* 52: 5 */ "additional_morekeys_symbols_0", + /* 53: 5 */ "morekeys_tablet_period", + /* 54: 4 */ "morekeys_nordic_row2_11", + /* 55: 4 */ "morekeys_punctuation", + /* 56: 4 */ "keyspec_tablet_comma", + /* 57: 4 */ "keyspec_period", + /* 58: 4 */ "morekeys_period", + /* 59: 4 */ "keyspec_tablet_period", + /* 60: 3 */ "keyspec_swiss_row1_11", + /* 61: 3 */ "keyspec_swiss_row2_10", + /* 62: 3 */ "keyspec_swiss_row2_11", + /* 63: 3 */ "morekeys_swiss_row1_11", + /* 64: 3 */ "morekeys_swiss_row2_10", + /* 65: 3 */ "morekeys_swiss_row2_11", + /* 66: 3 */ "morekeys_star", + /* 67: 3 */ "keyspec_left_parenthesis", + /* 68: 3 */ "keyspec_right_parenthesis", + /* 69: 3 */ "keyspec_left_square_bracket", + /* 70: 3 */ "keyspec_right_square_bracket", + /* 71: 3 */ "keyspec_left_curly_bracket", + /* 72: 3 */ "keyspec_right_curly_bracket", + /* 73: 3 */ "keyspec_less_than", + /* 74: 3 */ "keyspec_greater_than", + /* 75: 3 */ "keyspec_less_than_equal", + /* 76: 3 */ "keyspec_greater_than_equal", + /* 77: 3 */ "keyspec_left_double_angle_quote", + /* 78: 3 */ "keyspec_right_double_angle_quote", + /* 79: 3 */ "keyspec_left_single_angle_quote", + /* 80: 3 */ "keyspec_right_single_angle_quote", + /* 81: 3 */ "keyspec_comma", + /* 82: 3 */ "morekeys_tablet_comma", + /* 83: 3 */ "keyhintlabel_period", + /* 84: 3 */ "morekeys_question", + /* 85: 2 */ "morekeys_h", + /* 86: 2 */ "morekeys_w", + /* 87: 2 */ "morekeys_east_slavic_row2_2", + /* 88: 2 */ "morekeys_cyrillic_u", + /* 89: 2 */ "morekeys_cyrillic_en", + /* 90: 2 */ "morekeys_cyrillic_ghe", + /* 91: 2 */ "morekeys_cyrillic_o", + /* 92: 2 */ "morekeys_cyrillic_i", + /* 93: 2 */ "keyspec_south_slavic_row1_6", + /* 94: 2 */ "keyspec_south_slavic_row2_11", + /* 95: 2 */ "keyspec_south_slavic_row3_1", + /* 96: 2 */ "keyspec_south_slavic_row3_8", + /* 97: 2 */ "morekeys_tablet_punctuation", + /* 98: 2 */ "keyspec_spanish_row2_10", + /* 99: 2 */ "morekeys_bullet", + /* 100: 2 */ "morekeys_left_parenthesis", + /* 101: 2 */ "morekeys_right_parenthesis", + /* 102: 2 */ "morekeys_arabic_diacritics", + /* 103: 2 */ "keyhintlabel_tablet_comma", + /* 104: 2 */ "keyhintlabel_tablet_period", + /* 105: 2 */ "keyspec_symbols_question", + /* 106: 2 */ "keyspec_symbols_semicolon", + /* 107: 2 */ "keyspec_symbols_percent", + /* 108: 2 */ "morekeys_symbols_semicolon", + /* 109: 2 */ "morekeys_symbols_percent", + /* 110: 2 */ "label_go_key", + /* 111: 2 */ "label_send_key", + /* 112: 2 */ "label_next_key", + /* 113: 2 */ "label_done_key", + /* 114: 2 */ "label_search_key", + /* 115: 2 */ "label_previous_key", + /* 116: 2 */ "label_pause_key", + /* 117: 2 */ "label_wait_key", + /* 118: 1 */ "morekeys_v", + /* 119: 1 */ "morekeys_j", + /* 120: 1 */ "morekeys_q", + /* 121: 1 */ "morekeys_x", + /* 122: 1 */ "keyspec_q", + /* 123: 1 */ "keyspec_w", + /* 124: 1 */ "keyspec_y", + /* 125: 1 */ "keyspec_x", + /* 126: 1 */ "morekeys_east_slavic_row2_11", + /* 127: 1 */ "morekeys_cyrillic_ka", + /* 128: 1 */ "morekeys_cyrillic_a", + /* 129: 1 */ "morekeys_currency_dollar", + /* 130: 1 */ "morekeys_plus", + /* 131: 1 */ "morekeys_less_than", + /* 132: 1 */ "morekeys_greater_than", + /* 133: 1 */ "morekeys_exclamation", + /* 134: 0 */ "morekeys_currency_generic", + /* 135: 0 */ "morekeys_symbols_1", + /* 136: 0 */ "morekeys_symbols_2", + /* 137: 0 */ "morekeys_symbols_3", + /* 138: 0 */ "morekeys_symbols_4", + /* 139: 0 */ "morekeys_symbols_5", + /* 140: 0 */ "morekeys_symbols_6", + /* 141: 0 */ "morekeys_symbols_7", + /* 142: 0 */ "morekeys_symbols_8", + /* 143: 0 */ "morekeys_symbols_9", + /* 144: 0 */ "morekeys_symbols_0", + /* 145: 0 */ "morekeys_am_pm", + /* 146: 0 */ "keyspec_settings", + /* 147: 0 */ "keyspec_shortcut", + /* 148: 0 */ "keyspec_action_next", + /* 149: 0 */ "keyspec_action_previous", + /* 150: 0 */ "keylabel_to_more_symbol", + /* 151: 0 */ "keylabel_tablet_to_more_symbol", + /* 152: 0 */ "keylabel_to_phone_numeric", + /* 153: 0 */ "keylabel_to_phone_symbols", + /* 154: 0 */ "keylabel_time_am", + /* 155: 0 */ "keylabel_time_pm", + /* 156: 0 */ "keyspec_popular_domain", + /* 157: 0 */ "morekeys_popular_domain", + /* 158: 0 */ "keyspecs_left_parenthesis_more_keys", + /* 159: 0 */ "keyspecs_right_parenthesis_more_keys", + /* 160: 0 */ "single_laqm_raqm", + /* 161: 0 */ "single_raqm_laqm", + /* 162: 0 */ "double_laqm_raqm", + /* 163: 0 */ "double_raqm_laqm", + /* 164: 0 */ "single_lqm_rqm", + /* 165: 0 */ "single_9qm_lqm", + /* 166: 0 */ "single_9qm_rqm", + /* 167: 0 */ "single_rqm_9qm", + /* 168: 0 */ "double_lqm_rqm", + /* 169: 0 */ "double_9qm_lqm", + /* 170: 0 */ "double_9qm_rqm", + /* 171: 0 */ "double_rqm_9qm", + /* 172: 0 */ "morekeys_single_quote", + /* 173: 0 */ "morekeys_double_quote", + /* 174: 0 */ "morekeys_tablet_double_quote", + /* 175: 0 */ "keyspec_emoji_action_key", + }; + + private static final String EMPTY = ""; + + /* Default texts */ + private static final String[] TEXTS_DEFAULT = { + /* morekeys_a ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + /* keylabel_to_alpha */ "ABC", + /* morekeys_i ~ */ + EMPTY, EMPTY, EMPTY, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_lqm_rqm", + /* morekeys_s */ EMPTY, + /* single_quotes */ "!text/single_lqm_rqm", + /* keyspec_currency */ "$", + /* morekeys_y ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_laqm_raqm", + /* double_angle_quotes */ "!text/double_laqm_raqm", + /* morekeys_r ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ morekeys_cyrillic_soft_sign */ + /* keyspec_symbols_1 */ "1", + /* keyspec_symbols_2 */ "2", + /* keyspec_symbols_3 */ "3", + /* keyspec_symbols_4 */ "4", + /* keyspec_symbols_5 */ "5", + /* keyspec_symbols_6 */ "6", + /* keyspec_symbols_7 */ "7", + /* keyspec_symbols_8 */ "8", + /* keyspec_symbols_9 */ "9", + /* keyspec_symbols_0 */ "0", + // Label for "switch to symbols" key. + /* keylabel_to_symbol */ "?123", + /* additional_morekeys_symbols_1 ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ additional_morekeys_symbols_0 */ + /* morekeys_tablet_period */ "!text/morekeys_tablet_punctuation", + /* morekeys_nordic_row2_11 */ EMPTY, + /* morekeys_punctuation */ "!autoColumnOrder!8,\\,,?,!,#,!text/keyspec_right_parenthesis,!text/keyspec_left_parenthesis,/,;,',@,:,-,\",+,\\%,&", + /* keyspec_tablet_comma */ ",", + // Period key + /* keyspec_period */ ".", + /* morekeys_period */ "!text/morekeys_punctuation", + /* keyspec_tablet_period */ ".", + /* keyspec_swiss_row1_11 ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ morekeys_swiss_row2_11 */ + // U+2020: "†" DAGGER + // U+2021: "‡" DOUBLE DAGGER + // U+2605: "★" BLACK STAR + /* morekeys_star */ "\u2020,\u2021,\u2605", + // The all letters need to be mirrored are found at + // http://www.unicode.org/Public/6.1.0/ucd/BidiMirroring.txt + // U+2039: "‹" SINGLE LEFT-POINTING ANGLE QUOTATION MARK + // U+203A: "›" SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + // U+2264: "≤" LESS-THAN OR EQUAL TO + // U+2265: "≥" GREATER-THAN EQUAL TO + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + /* keyspec_left_parenthesis */ "(", + /* keyspec_right_parenthesis */ ")", + /* keyspec_left_square_bracket */ "[", + /* keyspec_right_square_bracket */ "]", + /* keyspec_left_curly_bracket */ "{", + /* keyspec_right_curly_bracket */ "}", + /* keyspec_less_than */ "<", + /* keyspec_greater_than */ ">", + /* keyspec_less_than_equal */ "\u2264", + /* keyspec_greater_than_equal */ "\u2265", + /* keyspec_left_double_angle_quote */ "\u00AB", + /* keyspec_right_double_angle_quote */ "\u00BB", + /* keyspec_left_single_angle_quote */ "\u2039", + /* keyspec_right_single_angle_quote */ "\u203A", + // Comma key + /* keyspec_comma */ ",", + /* morekeys_tablet_comma */ EMPTY, + /* keyhintlabel_period */ EMPTY, + // U+00BF: "¿" INVERTED QUESTION MARK + /* morekeys_question */ "\u00BF", + /* morekeys_h ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ keyspec_south_slavic_row3_8 */ + /* morekeys_tablet_punctuation */ "!autoColumnOrder!7,\\,,',#,!text/keyspec_right_parenthesis,!text/keyspec_left_parenthesis,/,;,@,:,-,\",+,\\%,&", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* keyspec_spanish_row2_10 */ "\u00F1", + // U+266A: "♪" EIGHTH NOTE + // U+2665: "♥" BLACK HEART SUIT + // U+2660: "♠" BLACK SPADE SUIT + // U+2666: "♦" BLACK DIAMOND SUIT + // U+2663: "♣" BLACK CLUB SUIT + /* morekeys_bullet */ "\u266A,\u2665,\u2660,\u2666,\u2663", + /* morekeys_left_parenthesis */ "!fixedColumnOrder!3,!text/keyspecs_left_parenthesis_more_keys", + /* morekeys_right_parenthesis */ "!fixedColumnOrder!3,!text/keyspecs_right_parenthesis_more_keys", + /* morekeys_arabic_diacritics ~ */ + EMPTY, EMPTY, EMPTY, + /* ~ keyhintlabel_tablet_period */ + /* keyspec_symbols_question */ "?", + /* keyspec_symbols_semicolon */ ";", + /* keyspec_symbols_percent */ "%", + /* morekeys_symbols_semicolon */ EMPTY, + // U+2030: "‰" PER MILLE SIGN + /* morekeys_symbols_percent */ "\u2030", + /* label_go_key */ "!string/label_go_key", + /* label_send_key */ "!string/label_send_key", + /* label_next_key */ "!string/label_next_key", + /* label_done_key */ "!string/label_done_key", + /* label_search_key */ "!string/label_search_key", + /* label_previous_key */ "!string/label_previous_key", + /* label_pause_key */ "!string/label_pause_key", + /* label_wait_key */ "!string/label_wait_key", + /* morekeys_v ~ */ + EMPTY, EMPTY, EMPTY, EMPTY, + /* ~ morekeys_x */ + /* keyspec_q */ "q", + /* keyspec_w */ "w", + /* keyspec_y */ "y", + /* keyspec_x */ "x", + /* morekeys_east_slavic_row2_11 ~ */ + EMPTY, EMPTY, EMPTY, + /* ~ morekeys_cyrillic_a */ + // U+00A2: "¢" CENT SIGN + // U+00A3: "£" POUND SIGN + // U+20AC: "€" EURO SIGN + // U+00A5: "¥" YEN SIGN + // U+20B1: "₱" PESO SIGN + /* morekeys_currency_dollar */ "\u00A2,\u00A3,\u20AC,\u00A5,\u20B1", + // U+00B1: "±" PLUS-MINUS SIGN + /* morekeys_plus */ "\u00B1", + /* morekeys_less_than */ "!fixedColumnOrder!3,!text/keyspec_left_single_angle_quote,!text/keyspec_less_than_equal,!text/keyspec_left_double_angle_quote", + /* morekeys_greater_than */ "!fixedColumnOrder!3,!text/keyspec_right_single_angle_quote,!text/keyspec_greater_than_equal,!text/keyspec_right_double_angle_quote", + // U+00A1: "¡" INVERTED EXCLAMATION MARK + /* morekeys_exclamation */ "\u00A1", + /* morekeys_currency_generic */ "$,\u00A2,\u20AC,\u00A3,\u00A5,\u20B1", + // U+00B9: "¹" SUPERSCRIPT ONE + // U+00BD: "½" VULGAR FRACTION ONE HALF + // U+2153: "⅓" VULGAR FRACTION ONE THIRD + // U+00BC: "¼" VULGAR FRACTION ONE QUARTER + // U+215B: "⅛" VULGAR FRACTION ONE EIGHTH + /* morekeys_symbols_1 */ "\u00B9,\u00BD,\u2153,\u00BC,\u215B", + // U+00B2: "²" SUPERSCRIPT TWO + // U+2154: "⅔" VULGAR FRACTION TWO THIRDS + /* morekeys_symbols_2 */ "\u00B2,\u2154", + // U+00B3: "³" SUPERSCRIPT THREE + // U+00BE: "¾" VULGAR FRACTION THREE QUARTERS + // U+215C: "⅜" VULGAR FRACTION THREE EIGHTHS + /* morekeys_symbols_3 */ "\u00B3,\u00BE,\u215C", + // U+2074: "⁴" SUPERSCRIPT FOUR + /* morekeys_symbols_4 */ "\u2074", + // U+215D: "⅝" VULGAR FRACTION FIVE EIGHTHS + /* morekeys_symbols_5 */ "\u215D", + /* morekeys_symbols_6 */ EMPTY, + // U+215E: "⅞" VULGAR FRACTION SEVEN EIGHTHS + /* morekeys_symbols_7 */ "\u215E", + /* morekeys_symbols_8 */ EMPTY, + /* morekeys_symbols_9 */ EMPTY, + // U+207F: "ⁿ" SUPERSCRIPT LATIN SMALL LETTER N + // U+2205: "∅" EMPTY SET + /* morekeys_symbols_0 */ "\u207F,\u2205", + /* morekeys_am_pm */ "!fixedColumnOrder!2,!hasLabels!,!text/keylabel_time_am,!text/keylabel_time_pm", + /* keyspec_settings */ "!icon/settings_key|!code/key_settings", + /* keyspec_shortcut */ "!icon/shortcut_key|!code/key_shortcut", + /* keyspec_action_next */ "!hasLabels!,!text/label_next_key|!code/key_action_next", + /* keyspec_action_previous */ "!hasLabels!,!text/label_previous_key|!code/key_action_previous", + // Label for "switch to more symbol" modifier key ("= \ <"). Must be short to fit on key! + /* keylabel_to_more_symbol */ "= \\\\ <", + // Label for "switch to more symbol" modifier key on tablets. Must be short to fit on key! + /* keylabel_tablet_to_more_symbol */ "~ [ <", + // Label for "switch to phone numeric" key. Must be short to fit on key! + /* keylabel_to_phone_numeric */ "123", + // Label for "switch to phone symbols" key. Must be short to fit on key! + // U+FF0A: "*" FULLWIDTH ASTERISK + // U+FF03: "#" FULLWIDTH NUMBER SIGN + /* keylabel_to_phone_symbols */ "\uFF0A\uFF03", + // Key label for "ante meridiem" + /* keylabel_time_am */ "AM", + // Key label for "post meridiem" + /* keylabel_time_pm */ "PM", + /* keyspec_popular_domain */ ".com", + // popular web domains for the locale - most popular, displayed on the keyboard + /* morekeys_popular_domain */ "!hasLabels!,.net,.org,.gov,.edu", + /* keyspecs_left_parenthesis_more_keys */ "!text/keyspec_less_than,!text/keyspec_left_curly_bracket,!text/keyspec_left_square_bracket", + /* keyspecs_right_parenthesis_more_keys */ "!text/keyspec_greater_than,!text/keyspec_right_curly_bracket,!text/keyspec_right_square_bracket", + // The following characters don't need BIDI mirroring. + // U+2018: "‘" LEFT SINGLE QUOTATION MARK + // U+2019: "’" RIGHT SINGLE QUOTATION MARK + // U+201A: "‚" SINGLE LOW-9 QUOTATION MARK + // U+201C: "“" LEFT DOUBLE QUOTATION MARK + // U+201D: "”" RIGHT DOUBLE QUOTATION MARK + // U+201E: "„" DOUBLE LOW-9 QUOTATION MARK + // Abbreviations are: + // laqm: LEFT-POINTING ANGLE QUOTATION MARK + // raqm: RIGHT-POINTING ANGLE QUOTATION MARK + // lqm: LEFT QUOTATION MARK + // rqm: RIGHT QUOTATION MARK + // 9qm: LOW-9 QUOTATION MARK + // The following each quotation mark pair consist of + // <opening quotation mark>, <closing quotation mark> + // and is named after (single|double)_<opening quotation mark>_<closing quotation mark>. + /* single_laqm_raqm */ "!text/keyspec_left_single_angle_quote,!text/keyspec_right_single_angle_quote", + /* single_raqm_laqm */ "!text/keyspec_right_single_angle_quote,!text/keyspec_left_single_angle_quote", + /* double_laqm_raqm */ "!text/keyspec_left_double_angle_quote,!text/keyspec_right_double_angle_quote", + /* double_raqm_laqm */ "!text/keyspec_right_double_angle_quote,!text/keyspec_left_double_angle_quote", + // The following each quotation mark triplet consists of + // <another quotation mark>, <opening quotation mark>, <closing quotation mark> + // and is named after (single|double)_<opening quotation mark>_<closing quotation mark>. + /* single_lqm_rqm */ "\u201A,\u2018,\u2019", + /* single_9qm_lqm */ "\u2019,\u201A,\u2018", + /* single_9qm_rqm */ "\u2018,\u201A,\u2019", + /* single_rqm_9qm */ "\u2018,\u2019,\u201A", + /* double_lqm_rqm */ "\u201E,\u201C,\u201D", + /* double_9qm_lqm */ "\u201D,\u201E,\u201C", + /* double_9qm_rqm */ "\u201C,\u201E,\u201D", + /* double_rqm_9qm */ "\u201C,\u201D,\u201E", + /* morekeys_single_quote */ "!fixedColumnOrder!5,!text/single_quotes,!text/single_angle_quotes", + /* morekeys_double_quote */ "!fixedColumnOrder!5,!text/double_quotes,!text/double_angle_quotes", + /* morekeys_tablet_double_quote */ "!fixedColumnOrder!6,!text/double_quotes,!text/single_quotes,!text/double_angle_quotes,!text/single_angle_quotes", + /* keyspec_emoji_action_key */ "!icon/emoji_action_key|!code/key_emoji", + }; + + /* Locale af: Afrikaans */ + private static final String[] TEXTS_af = { + // This is the same as Dutch except more keys of y and demoting vowels with diaeresis. + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E1,\u00E2,\u00E4,\u00E0,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F4,\u00F6,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FB,\u00FC,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_i */ "\u00ED,\u00EC,\u00EF,\u00EE,\u012F,\u012B,\u0133", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + /* morekeys_c ~ */ + null, null, null, null, null, + /* ~ keyspec_currency */ + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_y */ "\u00FD,\u0133", + }; + + /* Locale ar: Arabic */ + private static final String[] TEXTS_ar = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0623: "أ" ARABIC LETTER ALEF WITH HAMZA ABOVE + // U+200C: ZERO WIDTH NON-JOINER + // U+0628: "ب" ARABIC LETTER BEH + // U+062C: "ج" ARABIC LETTER JEEM + /* keylabel_to_alpha */ "\u0623\u200C\u0628\u200C\u062C", + /* morekeys_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_cyrillic_soft_sign */ + // U+0661: "١" ARABIC-INDIC DIGIT ONE + /* keyspec_symbols_1 */ "\u0661", + // U+0662: "٢" ARABIC-INDIC DIGIT TWO + /* keyspec_symbols_2 */ "\u0662", + // U+0663: "٣" ARABIC-INDIC DIGIT THREE + /* keyspec_symbols_3 */ "\u0663", + // U+0664: "٤" ARABIC-INDIC DIGIT FOUR + /* keyspec_symbols_4 */ "\u0664", + // U+0665: "٥" ARABIC-INDIC DIGIT FIVE + /* keyspec_symbols_5 */ "\u0665", + // U+0666: "٦" ARABIC-INDIC DIGIT SIX + /* keyspec_symbols_6 */ "\u0666", + // U+0667: "٧" ARABIC-INDIC DIGIT SEVEN + /* keyspec_symbols_7 */ "\u0667", + // U+0668: "٨" ARABIC-INDIC DIGIT EIGHT + /* keyspec_symbols_8 */ "\u0668", + // U+0669: "٩" ARABIC-INDIC DIGIT NINE + /* keyspec_symbols_9 */ "\u0669", + // U+0660: "٠" ARABIC-INDIC DIGIT ZERO + /* keyspec_symbols_0 */ "\u0660", + // Label for "switch to symbols" key. + // U+061F: "؟" ARABIC QUESTION MARK + /* keylabel_to_symbol */ "\u0663\u0662\u0661\u061F", + /* additional_morekeys_symbols_1 */ "1", + /* additional_morekeys_symbols_2 */ "2", + /* additional_morekeys_symbols_3 */ "3", + /* additional_morekeys_symbols_4 */ "4", + /* additional_morekeys_symbols_5 */ "5", + /* additional_morekeys_symbols_6 */ "6", + /* additional_morekeys_symbols_7 */ "7", + /* additional_morekeys_symbols_8 */ "8", + /* additional_morekeys_symbols_9 */ "9", + // U+066B: "٫" ARABIC DECIMAL SEPARATOR + // U+066C: "٬" ARABIC THOUSANDS SEPARATOR + /* additional_morekeys_symbols_0 */ "0,\u066B,\u066C", + /* morekeys_tablet_period */ "!text/morekeys_arabic_diacritics", + /* morekeys_nordic_row2_11 */ null, + /* morekeys_punctuation */ null, + // U+061F: "؟" ARABIC QUESTION MARK + // U+060C: "،" ARABIC COMMA + // U+061B: "؛" ARABIC SEMICOLON + /* keyspec_tablet_comma */ "\u060C", + /* keyspec_period */ null, + /* morekeys_period */ "!text/morekeys_arabic_diacritics", + /* keyspec_tablet_period ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_swiss_row2_11 */ + // U+2605: "★" BLACK STAR + // U+066D: "٭" ARABIC FIVE POINTED STAR + /* morekeys_star */ "\u2605,\u066D", + // U+2264: "≤" LESS-THAN OR EQUAL TO + // U+2265: "≥" GREATER-THAN EQUAL TO + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+2039: "‹" SINGLE LEFT-POINTING ANGLE QUOTATION MARK + // U+203A: "›" SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + /* keyspec_left_parenthesis */ "(|)", + /* keyspec_right_parenthesis */ ")|(", + /* keyspec_left_square_bracket */ "[|]", + /* keyspec_right_square_bracket */ "]|[", + /* keyspec_left_curly_bracket */ "{|}", + /* keyspec_right_curly_bracket */ "}|{", + /* keyspec_less_than */ "<|>", + /* keyspec_greater_than */ ">|<", + /* keyspec_less_than_equal */ "\u2264|\u2265", + /* keyspec_greater_than_equal */ "\u2265|\u2264", + /* keyspec_left_double_angle_quote */ "\u00AB|\u00BB", + /* keyspec_right_double_angle_quote */ "\u00BB|\u00AB", + /* keyspec_left_single_angle_quote */ "\u2039|\u203A", + /* keyspec_right_single_angle_quote */ "\u203A|\u2039", + // U+060C: "،" ARABIC COMMA + /* keyspec_comma */ "\u060C", + /* morekeys_tablet_comma */ "!fixedColumnOrder!4,:,!,\u061F,\u061B,-,\",\'", + // U+0651: "ّ" ARABIC SHADDA + /* keyhintlabel_period */ "\u0651", + // U+00BF: "¿" INVERTED QUESTION MARK + /* morekeys_question */ "?,\u00BF", + /* morekeys_h ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ keyspec_spanish_row2_10 */ + // U+266A: "♪" EIGHTH NOTE + /* morekeys_bullet */ "\u266A", + // The all letters need to be mirrored are found at + // http://www.unicode.org/Public/6.1.0/ucd/BidiMirroring.txt + // U+FD3E: "﴾" ORNATE LEFT PARENTHESIS + // U+FD3F: "﴿" ORNATE RIGHT PARENTHESIS + /* morekeys_left_parenthesis */ "!fixedColumnOrder!4,\uFD3E|\uFD3F,!text/keyspecs_left_parenthesis_more_keys", + /* morekeys_right_parenthesis */ "!fixedColumnOrder!4,\uFD3F|\uFD3E,!text/keyspecs_right_parenthesis_more_keys", + // U+0655: "ٕ" ARABIC HAMZA BELOW + // U+0654: "ٔ" ARABIC HAMZA ABOVE + // U+0652: "ْ" ARABIC SUKUN + // U+064D: "ٍ" ARABIC KASRATAN + // U+064C: "ٌ" ARABIC DAMMATAN + // U+064B: "ً" ARABIC FATHATAN + // U+0651: "ّ" ARABIC SHADDA + // U+0656: "ٖ" ARABIC SUBSCRIPT ALEF + // U+0670: "ٰ" ARABIC LETTER SUPERSCRIPT ALEF + // U+0653: "ٓ" ARABIC MADDAH ABOVE + // U+0650: "ِ" ARABIC KASRA + // U+064F: "ُ" ARABIC DAMMA + // U+064E: "َ" ARABIC FATHA + // U+0640: "ـ" ARABIC TATWEEL + // In order to make Tatweel easily distinguishable from other punctuations, we use consecutive Tatweels only for its displayed label. + // Note: The space character is needed as a preceding letter to draw Arabic diacritics characters correctly. + /* morekeys_arabic_diacritics */ "!fixedColumnOrder!7, \u0655|\u0655, \u0654|\u0654, \u0652|\u0652, \u064D|\u064D, \u064C|\u064C, \u064B|\u064B, \u0651|\u0651, \u0656|\u0656, \u0670|\u0670, \u0653|\u0653, \u0650|\u0650, \u064F|\u064F, \u064E|\u064E,\u0640\u0640\u0640|\u0640", + /* keyhintlabel_tablet_comma */ "\u061F", + /* keyhintlabel_tablet_period */ "\u0651", + /* keyspec_symbols_question */ "\u061F", + /* keyspec_symbols_semicolon */ "\u061B", + // U+066A: "٪" ARABIC PERCENT SIGN + /* keyspec_symbols_percent */ "\u066A", + /* morekeys_symbols_semicolon */ ";", + // U+2030: "‰" PER MILLE SIGN + /* morekeys_symbols_percent */ "\\%,\u2030", + }; + + /* Locale az_AZ: Azerbaijani (Azerbaijan) */ + private static final String[] TEXTS_az_AZ = { + // This is the same as Turkish + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + /* morekeys_a */ "\u00E2,\u00E4,\u00E1", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,\u00F4,\u0153,\u00F2,\u00F3,\u00F5,\u00F8,\u014D", + // U+0259: "ə" LATIN SMALL LETTER SCHWA + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* morekeys_e */ "\u0259,\u00E9", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,\u00FB,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u0131,\u00EE,\u00EF,\u00EC,\u00ED,\u012F,\u012B", + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u0148,\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes */ null, + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u015F,\u00DF,\u015B,\u0161", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + /* morekeys_y */ "\u00FD", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017E", + /* morekeys_d ~ */ + null, null, null, + /* ~ morekeys_l */ + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u011F", + }; + + /* Locale be_BY: Belarusian (Belarus) */ + private static final String[] TEXTS_be_BY = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_k */ + // U+0451: "ё" CYRILLIC SMALL LETTER IO + /* morekeys_cyrillic_ie */ "\u0451", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, + /* ~ morekeys_nordic_row2_10 */ + // U+045E: "ў" CYRILLIC SMALL LETTER SHORT U + /* keyspec_east_slavic_row1_9 */ "\u045E", + // U+044B: "ы" CYRILLIC SMALL LETTER YERU + /* keyspec_east_slavic_row2_2 */ "\u044B", + // U+044D: "э" CYRILLIC SMALL LETTER E + /* keyspec_east_slavic_row2_11 */ "\u044D", + // U+0456: "і" CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I + /* keyspec_east_slavic_row3_5 */ "\u0456", + // U+044A: "ъ" CYRILLIC SMALL LETTER HARD SIGN + /* morekeys_cyrillic_soft_sign */ "\u044A", + }; + + /* Locale bg: Bulgarian */ + private static final String[] TEXTS_bg = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + // single_quotes of Bulgarian is default single_quotes_right_left. + /* double_quotes */ "!text/double_9qm_lqm", + }; + + /* Locale bn_BD: Bengali (Bangladesh) */ + private static final String[] TEXTS_bn_BD = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0995: "क" BENGALI LETTER KA + // U+0996: "ख" BENGALI LETTER KHA + // U+0997: "ग" BENGALI LETTER GA + /* keylabel_to_alpha */ "\u0995\u0996\u0997", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+09F3: "৳" BENGALI RUPEE SIGN + /* keyspec_currency */ "\u09F3", + }; + + /* Locale bn_IN: Bengali (India) */ + private static final String[] TEXTS_bn_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0995: "क" BENGALI LETTER KA + // U+0996: "ख" BENGALI LETTER KHA + // U+0997: "ग" BENGALI LETTER GA + /* keylabel_to_alpha */ "\u0995\u0996\u0997", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + }; + + /* Locale ca: Catalan */ + private static final String[] TEXTS_ca = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E0,\u00E1,\u00E4,\u00E2,\u00E3,\u00E5,\u0105,\u00E6,\u0101,\u00AA", + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F2,\u00F3,\u00F6,\u00F4,\u00F5,\u00F8,\u0153,\u014D,\u00BA", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E8,\u00E9,\u00EB,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes ~ */ + null, null, null, null, null, null, null, null, + /* ~ morekeys_t */ + // U+00B7: "·" MIDDLE DOT + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "l\u00B7l,\u0142", + /* morekeys_g ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + /* ~ morekeys_nordic_row2_11 */ + // U+00B7: "·" MIDDLE DOT + /* morekeys_punctuation */ "!autoColumnOrder!9,\\,,?,!,\u00B7,#,),(,/,;,',@,:,-,\",+,\\%,&", + /* keyspec_tablet_comma ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, + /* ~ keyspec_south_slavic_row3_8 */ + /* morekeys_tablet_punctuation */ "!autoColumnOrder!8,\\,,',\u00B7,#,),(,/,;,@,:,-,\",+,\\%,&", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + /* keyspec_spanish_row2_10 */ "\u00E7", + }; + + /* Locale cs: Czech */ + private static final String[] TEXTS_cs = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E1,\u00E0,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F6,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u011B,\u00E8,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u016F,\u00FB,\u00FC,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EE,\u00EF,\u00EC,\u012F,\u012B", + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u0148,\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u00E7,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + /* morekeys_s */ "\u0161,\u00DF,\u015B", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + /* morekeys_z */ "\u017E,\u017A,\u017C", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u010F", + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + /* morekeys_t */ "\u0165", + /* morekeys_l */ null, + /* morekeys_g */ null, + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + /* morekeys_r */ "\u0159", + }; + + /* Locale da: Danish */ + private static final String[] TEXTS_da = { + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E5,\u00E6,\u00E1,\u00E4,\u00E0,\u00E2,\u00E3,\u0101", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F8,\u00F6,\u00F3,\u00F4,\u00F2,\u00F5,\u0153,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + /* morekeys_e */ "\u00E9,\u00EB", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00FB,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + /* morekeys_i */ "\u00ED,\u00EF", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_lqm", + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u00DF,\u015B,\u0161", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + /* morekeys_z */ null, + // U+00F0: "ð" LATIN SMALL LETTER ETH + /* morekeys_d */ "\u00F0", + /* morekeys_t */ null, + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u0142", + /* morekeys_g */ null, + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + /* morekeys_r ~ */ + null, null, null, + /* ~ morekeys_cyrillic_ie */ + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + /* keyspec_nordic_row1_11 */ "\u00E5", + // U+00E6: "æ" LATIN SMALL LETTER AE + /* keyspec_nordic_row2_10 */ "\u00E6", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* keyspec_nordic_row2_11 */ "\u00F8", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* morekeys_nordic_row2_10 */ "\u00E4", + /* keyspec_east_slavic_row1_9 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_tablet_period */ + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* morekeys_nordic_row2_11 */ "\u00F6", + }; + + /* Locale de: German */ + private static final String[] TEXTS_de = { + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E4,%,\u00E2,\u00E0,\u00E1,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,%,\u00F4,\u00F2,\u00F3,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0117", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,%,\u00FB,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + /* morekeys_i */ null, + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_lqm", + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u00DF,\u015B,\u0161", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + /* morekeys_r ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + /* ~ keyspec_tablet_period */ + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + /* keyspec_swiss_row1_11 */ "\u00FC", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* keyspec_swiss_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* keyspec_swiss_row2_11 */ "\u00E4", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + /* morekeys_swiss_row1_11 */ "\u00E8", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* morekeys_swiss_row2_10 */ "\u00E9", + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + /* morekeys_swiss_row2_11 */ "\u00E0", + }; + + /* Locale el: Greek */ + private static final String[] TEXTS_el = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0391: "Α" GREEK CAPITAL LETTER ALPHA + // U+0392: "Β" GREEK CAPITAL LETTER BETA + // U+0393: "Γ" GREEK CAPITAL LETTER GAMMA + /* keylabel_to_alpha */ "\u0391\u0392\u0393", + }; + + /* Locale en: English */ + private static final String[] TEXTS_en = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E0,\u00E1,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + /* morekeys_o */ "\u00F3,\u00F4,\u00F6,\u00F2,\u0153,\u00F8,\u014D,\u00F5", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FB,\u00FC,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + /* morekeys_i */ "\u00ED,\u00EE,\u00EF,\u012B,\u00EC", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + /* morekeys_c */ "\u00E7", + /* double_quotes */ null, + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + /* morekeys_s */ "\u00DF", + }; + + /* Locale eo: Esperanto */ + private static final String[] TEXTS_eo = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+0103: "ă" LATIN SMALL LETTER A WITH BREVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E0,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101,\u0103,\u0105,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F6,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D,\u0151,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u011B,\u00E8,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+0169: "ũ" LATIN SMALL LETTER U WITH TILDE + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + // U+00B5: "µ" MICRO SIGN + /* morekeys_u */ "\u00FA,\u016F,\u00FB,\u00FC,\u00F9,\u016B,\u0169,\u0171,\u0173,\u00B5", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0129: "ĩ" LATIN SMALL LETTER I WITH TILDE + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_i */ "\u00ED,\u00EE,\u00EF,\u0129,\u00EC,\u012F,\u012B,\u0131,\u0133", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+0149: "ʼn" LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + // U+014B: "ŋ" LATIN SMALL LETTER ENG + /* morekeys_n */ "\u00F1,\u0144,\u0146,\u0148,\u0149,\u014B", + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+010B: "ċ" LATIN SMALL LETTER C WITH DOT ABOVE + /* morekeys_c */ "\u0107,\u010D,\u00E7,\u010B", + /* double_quotes */ null, + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0219: "ș" LATIN SMALL LETTER S WITH COMMA BELOW + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + /* morekeys_s */ "\u00DF,\u0161,\u015B,\u0219,\u015F", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+0177: "ŷ" LATIN SMALL LETTER Y WITH CIRCUMFLEX + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + // U+00FE: "þ" LATIN SMALL LETTER THORN + /* morekeys_y */ "y,\u00FD,\u0177,\u00FF,\u00FE", + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017A,\u017C,\u017E", + // U+00F0: "ð" LATIN SMALL LETTER ETH + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + /* morekeys_d */ "\u00F0,\u010F,\u0111", + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + // U+021B: "ț" LATIN SMALL LETTER T WITH COMMA BELOW + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + // U+0167: "ŧ" LATIN SMALL LETTER T WITH STROKE + /* morekeys_t */ "\u0165,\u021B,\u0163,\u0167", + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + // U+0140: "ŀ" LATIN SMALL LETTER L WITH MIDDLE DOT + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u013A,\u013C,\u013E,\u0140,\u0142", + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + // U+0121: "ġ" LATIN SMALL LETTER G WITH DOT ABOVE + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + /* morekeys_g */ "\u011F,\u0121,\u0123", + /* single_angle_quotes */ null, + /* double_angle_quotes */ null, + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + /* morekeys_r */ "\u0159,\u0155,\u0157", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + // U+0138: "ĸ" LATIN SMALL LETTER KRA + /* morekeys_k */ "\u0137,\u0138", + /* morekeys_cyrillic_ie ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, + /* ~ morekeys_question */ + // U+0125: "ĥ" LATIN SMALL LETTER H WITH CIRCUMFLEX + // U+0127: "ħ" LATIN SMALL LETTER H WITH STROKE + /* morekeys_h */ "\u0125,\u0127", + // U+0175: "ŵ" LATIN SMALL LETTER W WITH CIRCUMFLEX + /* morekeys_w */ "w,\u0175", + /* morekeys_east_slavic_row2_2 ~ */ + null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_tablet_punctuation */ + // U+0135: "ĵ" LATIN SMALL LETTER J WITH CIRCUMFLEX + /* keyspec_spanish_row2_10 */ "\u0135", + /* morekeys_bullet ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + /* ~ label_wait_key */ + // U+0175: "ŵ" LATIN SMALL LETTER W WITH CIRCUMFLEX + /* morekeys_v */ "w,\u0175", + /* morekeys_j */ null, + /* morekeys_q */ "q", + /* morekeys_x */ "x", + // U+015D: "ŝ" LATIN SMALL LETTER S WITH CIRCUMFLEX + /* keyspec_q */ "\u015D", + // U+011D: "ĝ" LATIN SMALL LETTER G WITH CIRCUMFLEX + /* keyspec_w */ "\u011D", + // U+016D: "ŭ" LATIN SMALL LETTER U WITH BREVE + /* keyspec_y */ "\u016D", + // U+0109: "ĉ" LATIN SMALL LETTER C WITH CIRCUMFLEX + /* keyspec_x */ "\u0109", + }; + + /* Locale es: Spanish */ + private static final String[] TEXTS_es = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E0,\u00E4,\u00E2,\u00E3,\u00E5,\u0105,\u00E6,\u0101,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F2,\u00F6,\u00F4,\u00F5,\u00F8,\u0153,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EB,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, + /* ~ morekeys_nordic_row2_11 */ + // U+00A1: "¡" INVERTED EXCLAMATION MARK + // U+00BF: "¿" INVERTED QUESTION MARK + /* morekeys_punctuation */ "!autoColumnOrder!9,\\,,?,!,#,),(,/,;,\u00A1,',@,:,-,\",+,\\%,&,\u00BF", + }; + + /* Locale et_EE: Estonian (Estonia) */ + private static final String[] TEXTS_et_EE = { + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + /* morekeys_a */ "\u00E4,\u0101,\u00E0,\u00E1,\u00E2,\u00E3,\u00E5,\u00E6,\u0105", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_o */ "\u00F6,\u00F5,\u00F2,\u00F3,\u00F4,\u0153,\u0151,\u00F8", + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + /* morekeys_e */ "\u0113,\u00E8,\u0117,\u00E9,\u00EA,\u00EB,\u0119,\u011B", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + /* morekeys_u */ "\u00FC,\u016B,\u0173,\u00F9,\u00FA,\u00FB,\u016F,\u0171", + /* keylabel_to_alpha */ null, + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + /* morekeys_i */ "\u012B,\u00EC,\u012F,\u00ED,\u00EE,\u00EF,\u0131", + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u0146,\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u00E7,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + /* morekeys_s */ "\u0161,\u00DF,\u015B,\u015F", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + /* morekeys_z */ "\u017E,\u017C,\u017A", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u010F", + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + /* morekeys_t */ "\u0163,\u0165", + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + /* morekeys_l */ "\u013C,\u0142,\u013A,\u013E", + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u0123,\u011F", + /* single_angle_quotes */ null, + /* double_angle_quotes */ null, + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + /* morekeys_r */ "\u0157,\u0159,\u0155", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + /* morekeys_k */ "\u0137", + /* morekeys_cyrillic_ie */ null, + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + /* keyspec_nordic_row1_11 */ "\u00FC", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* keyspec_nordic_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* keyspec_nordic_row2_11 */ "\u00E4", + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + /* morekeys_nordic_row2_10 */ "\u00F5", + }; + + /* Locale eu_ES: Basque (Spain) */ + private static final String[] TEXTS_eu_ES = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E0,\u00E4,\u00E2,\u00E3,\u00E5,\u0105,\u00E6,\u0101,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F2,\u00F6,\u00F4,\u00F5,\u00F8,\u0153,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EB,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + }; + + /* Locale fa: Persian */ + private static final String[] TEXTS_fa = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0627: "ا" ARABIC LETTER ALEF + // U+200C: ZERO WIDTH NON-JOINER + // U+0628: "ب" ARABIC LETTER BEH + // U+067E: "پ" ARABIC LETTER PEH + /* keylabel_to_alpha */ "\u0627\u200C\u0628\u200C\u067E", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+FDFC: "﷼" RIAL SIGN + /* keyspec_currency */ "\uFDFC", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, + /* ~ morekeys_cyrillic_soft_sign */ + // U+06F1: "۱" EXTENDED ARABIC-INDIC DIGIT ONE + /* keyspec_symbols_1 */ "\u06F1", + // U+06F2: "۲" EXTENDED ARABIC-INDIC DIGIT TWO + /* keyspec_symbols_2 */ "\u06F2", + // U+06F3: "۳" EXTENDED ARABIC-INDIC DIGIT THREE + /* keyspec_symbols_3 */ "\u06F3", + // U+06F4: "۴" EXTENDED ARABIC-INDIC DIGIT FOUR + /* keyspec_symbols_4 */ "\u06F4", + // U+06F5: "۵" EXTENDED ARABIC-INDIC DIGIT FIVE + /* keyspec_symbols_5 */ "\u06F5", + // U+06F6: "۶" EXTENDED ARABIC-INDIC DIGIT SIX + /* keyspec_symbols_6 */ "\u06F6", + // U+06F7: "۷" EXTENDED ARABIC-INDIC DIGIT SEVEN + /* keyspec_symbols_7 */ "\u06F7", + // U+06F8: "۸" EXTENDED ARABIC-INDIC DIGIT EIGHT + /* keyspec_symbols_8 */ "\u06F8", + // U+06F9: "۹" EXTENDED ARABIC-INDIC DIGIT NINE + /* keyspec_symbols_9 */ "\u06F9", + // U+06F0: "۰" EXTENDED ARABIC-INDIC DIGIT ZERO + /* keyspec_symbols_0 */ "\u06F0", + // Label for "switch to symbols" key. + // U+061F: "؟" ARABIC QUESTION MARK + /* keylabel_to_symbol */ "\u06F3\u06F2\u06F1\u061F", + /* additional_morekeys_symbols_1 */ "1", + /* additional_morekeys_symbols_2 */ "2", + /* additional_morekeys_symbols_3 */ "3", + /* additional_morekeys_symbols_4 */ "4", + /* additional_morekeys_symbols_5 */ "5", + /* additional_morekeys_symbols_6 */ "6", + /* additional_morekeys_symbols_7 */ "7", + /* additional_morekeys_symbols_8 */ "8", + /* additional_morekeys_symbols_9 */ "9", + // U+066B: "٫" ARABIC DECIMAL SEPARATOR + // U+066C: "٬" ARABIC THOUSANDS SEPARATOR + /* additional_morekeys_symbols_0 */ "0,\u066B,\u066C", + /* morekeys_tablet_period */ "!text/morekeys_arabic_diacritics", + /* morekeys_nordic_row2_11 */ null, + /* morekeys_punctuation */ null, + // U+060C: "،" ARABIC COMMA + // U+061B: "؛" ARABIC SEMICOLON + // U+061F: "؟" ARABIC QUESTION MARK + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + /* keyspec_tablet_comma */ "\u060C", + /* keyspec_period */ null, + /* morekeys_period */ "!text/morekeys_arabic_diacritics", + /* keyspec_tablet_period ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_swiss_row2_11 */ + // U+2605: "★" BLACK STAR + // U+066D: "٭" ARABIC FIVE POINTED STAR + /* morekeys_star */ "\u2605,\u066D", + /* keyspec_left_parenthesis */ "(|)", + /* keyspec_right_parenthesis */ ")|(", + /* keyspec_left_square_bracket */ "[|]", + /* keyspec_right_square_bracket */ "]|[", + /* keyspec_left_curly_bracket */ "{|}", + /* keyspec_right_curly_bracket */ "}|{", + /* keyspec_less_than */ "<|>", + /* keyspec_greater_than */ ">|<", + /* keyspec_less_than_equal */ "\u2264|\u2265", + /* keyspec_greater_than_equal */ "\u2265|\u2264", + /* keyspec_left_double_angle_quote */ "\u00AB|\u00BB", + /* keyspec_right_double_angle_quote */ "\u00BB|\u00AB", + /* keyspec_left_single_angle_quote */ "\u2039|\u203A", + /* keyspec_right_single_angle_quote */ "\u203A|\u2039", + // U+060C: "،" ARABIC COMMA + /* keyspec_comma */ "\u060C", + /* morekeys_tablet_comma */ "!fixedColumnOrder!4,:,!,\u061F,\u061B,-,!text/keyspec_left_double_angle_quote,!text/keyspec_right_double_angle_quote", + // U+064B: "ً" ARABIC FATHATAN + /* keyhintlabel_period */ "\u064B", + // U+00BF: "¿" INVERTED QUESTION MARK + /* morekeys_question */ "?,\u00BF", + /* morekeys_h ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ keyspec_spanish_row2_10 */ + // U+266A: "♪" EIGHTH NOTE + /* morekeys_bullet */ "\u266A", + // The all letters need to be mirrored are found at + // http://www.unicode.org/Public/6.1.0/ucd/BidiMirroring.txt + // U+FD3E: "﴾" ORNATE LEFT PARENTHESIS + // U+FD3F: "﴿" ORNATE RIGHT PARENTHESIS + /* morekeys_left_parenthesis */ "!fixedColumnOrder!4,\uFD3E|\uFD3F,!text/keyspecs_left_parenthesis_more_keys", + /* morekeys_right_parenthesis */ "!fixedColumnOrder!4,\uFD3F|\uFD3E,!text/keyspecs_right_parenthesis_more_keys", + // U+0655: "ٕ" ARABIC HAMZA BELOW + // U+0652: "ْ" ARABIC SUKUN + // U+0651: "ّ" ARABIC SHADDA + // U+064C: "ٌ" ARABIC DAMMATAN + // U+064D: "ٍ" ARABIC KASRATAN + // U+064B: "ً" ARABIC FATHATAN + // U+0654: "ٔ" ARABIC HAMZA ABOVE + // U+0656: "ٖ" ARABIC SUBSCRIPT ALEF + // U+0670: "ٰ" ARABIC LETTER SUPERSCRIPT ALEF + // U+0653: "ٓ" ARABIC MADDAH ABOVE + // U+064F: "ُ" ARABIC DAMMA + // U+0650: "ِ" ARABIC KASRA + // U+064E: "َ" ARABIC FATHA + // U+0640: "ـ" ARABIC TATWEEL + // In order to make Tatweel easily distinguishable from other punctuations, we use consecutive Tatweels only for its displayed label. + // Note: The space character is needed as a preceding letter to draw Arabic diacritics characters correctly. + /* morekeys_arabic_diacritics */ "!fixedColumnOrder!7, \u0655|\u0655, \u0652|\u0652, \u0651|\u0651, \u064C|\u064C, \u064D|\u064D, \u064B|\u064B, \u0654|\u0654, \u0656|\u0656, \u0670|\u0670, \u0653|\u0653, \u064F|\u064F, \u0650|\u0650, \u064E|\u064E,\u0640\u0640\u0640|\u0640", + /* keyhintlabel_tablet_comma */ "\u061F", + /* keyhintlabel_tablet_period */ "\u064B", + /* keyspec_symbols_question */ "\u061F", + /* keyspec_symbols_semicolon */ "\u061B", + // U+066A: "٪" ARABIC PERCENT SIGN + /* keyspec_symbols_percent */ "\u066A", + /* morekeys_symbols_semicolon */ ";", + // U+2030: "‰" PER MILLE SIGN + /* morekeys_symbols_percent */ "\\%,\u2030", + /* label_go_key ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, + /* ~ morekeys_plus */ + // U+2264: "≤" LESS-THAN OR EQUAL TO + // U+2265: "≥" GREATER-THAN EQUAL TO + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+2039: "‹" SINGLE LEFT-POINTING ANGLE QUOTATION MARK + // U+203A: "›" SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + /* morekeys_less_than */ "!fixedColumnOrder!3,!text/keyspec_left_single_angle_quote,!text/keyspec_less_than_equal,!text/keyspec_less_than", + /* morekeys_greater_than */ "!fixedColumnOrder!3,!text/keyspec_right_single_angle_quote,!text/keyspec_greater_than_equal,!text/keyspec_greater_than", + }; + + /* Locale fi: Finnish */ + private static final String[] TEXTS_fi = { + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E4,\u00E5,\u00E6,\u00E0,\u00E1,\u00E2,\u00E3,\u0101", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,\u00F8,\u00F4,\u00F2,\u00F3,\u00F5,\u0153,\u014D", + /* morekeys_e */ null, + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + /* morekeys_u */ "\u00FC", + /* keylabel_to_alpha ~ */ + null, null, null, null, null, + /* ~ double_quotes */ + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + /* morekeys_s */ "\u0161,\u00DF,\u015B", + /* single_quotes ~ */ + null, null, null, + /* ~ morekeys_y */ + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + /* morekeys_z */ "\u017E,\u017A,\u017C", + /* morekeys_d ~ */ + null, null, null, null, null, null, null, null, null, + /* ~ morekeys_cyrillic_ie */ + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + /* keyspec_nordic_row1_11 */ "\u00E5", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* keyspec_nordic_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* keyspec_nordic_row2_11 */ "\u00E4", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_nordic_row2_10 */ "\u00F8", + /* keyspec_east_slavic_row1_9 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_tablet_period */ + // U+00E6: "æ" LATIN SMALL LETTER AE + /* morekeys_nordic_row2_11 */ "\u00E6", + }; + + /* Locale fr: French */ + private static final String[] TEXTS_fr = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E0,\u00E2,%,\u00E6,\u00E1,\u00E4,\u00E3,\u00E5,\u0101,\u00AA", + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F4,\u0153,%,\u00F6,\u00F2,\u00F3,\u00F5,\u00F8,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,%,\u0119,\u0117,\u0113", + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00F9,\u00FB,%,\u00FC,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00EE,%,\u00EF,\u00EC,\u00ED,\u012F,\u012B", + /* morekeys_n */ null, + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,%,\u0107,\u010D", + /* double_quotes ~ */ + null, null, null, null, + /* ~ keyspec_currency */ + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "%,\u00FF", + /* morekeys_z ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, + /* ~ keyspec_tablet_period */ + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + /* keyspec_swiss_row1_11 */ "\u00E8", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* keyspec_swiss_row2_10 */ "\u00E9", + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + /* keyspec_swiss_row2_11 */ "\u00E0", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + /* morekeys_swiss_row1_11 */ "\u00FC", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* morekeys_swiss_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* morekeys_swiss_row2_11 */ "\u00E4", + }; + + /* Locale gl_ES: Gallegan (Spain) */ + private static final String[] TEXTS_gl_ES = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E0,\u00E4,\u00E2,\u00E3,\u00E5,\u0105,\u00E6,\u0101,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F2,\u00F6,\u00F4,\u00F5,\u00F8,\u0153,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EB,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + }; + + /* Locale hi: Hindi */ + private static final String[] TEXTS_hi = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0915: "क" DEVANAGARI LETTER KA + // U+0916: "ख" DEVANAGARI LETTER KHA + // U+0917: "ग" DEVANAGARI LETTER GA + /* keylabel_to_alpha */ "\u0915\u0916\u0917", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, + /* ~ morekeys_cyrillic_soft_sign */ + // U+0967: "१" DEVANAGARI DIGIT ONE + /* keyspec_symbols_1 */ "\u0967", + // U+0968: "२" DEVANAGARI DIGIT TWO + /* keyspec_symbols_2 */ "\u0968", + // U+0969: "३" DEVANAGARI DIGIT THREE + /* keyspec_symbols_3 */ "\u0969", + // U+096A: "४" DEVANAGARI DIGIT FOUR + /* keyspec_symbols_4 */ "\u096A", + // U+096B: "५" DEVANAGARI DIGIT FIVE + /* keyspec_symbols_5 */ "\u096B", + // U+096C: "६" DEVANAGARI DIGIT SIX + /* keyspec_symbols_6 */ "\u096C", + // U+096D: "७" DEVANAGARI DIGIT SEVEN + /* keyspec_symbols_7 */ "\u096D", + // U+096E: "८" DEVANAGARI DIGIT EIGHT + /* keyspec_symbols_8 */ "\u096E", + // U+096F: "९" DEVANAGARI DIGIT NINE + /* keyspec_symbols_9 */ "\u096F", + // U+0966: "०" DEVANAGARI DIGIT ZERO + /* keyspec_symbols_0 */ "\u0966", + // Label for "switch to symbols" key. + /* keylabel_to_symbol */ "?\u0967\u0968\u0969", + /* additional_morekeys_symbols_1 */ "1", + /* additional_morekeys_symbols_2 */ "2", + /* additional_morekeys_symbols_3 */ "3", + /* additional_morekeys_symbols_4 */ "4", + /* additional_morekeys_symbols_5 */ "5", + /* additional_morekeys_symbols_6 */ "6", + /* additional_morekeys_symbols_7 */ "7", + /* additional_morekeys_symbols_8 */ "8", + /* additional_morekeys_symbols_9 */ "9", + /* additional_morekeys_symbols_0 */ "0", + /* morekeys_tablet_period */ "!autoColumnOrder!8,\\,,.,',#,),(,/,;,@,:,-,\",+,\\%,&", + /* morekeys_nordic_row2_11 ~ */ + null, null, null, + /* ~ keyspec_tablet_comma */ + // U+0964: "।" DEVANAGARI DANDA + /* keyspec_period */ "\u0964", + /* morekeys_period */ "!autoColumnOrder!9,\\,,.,?,!,#,),(,/,;,',@,:,-,\",+,\\%,&", + /* keyspec_tablet_period */ "\u0964", + }; + + /* Locale hi_ZZ: Hindi (ZZ) */ + private static final String[] TEXTS_hi_ZZ = { + /* morekeys_a ~ */ + null, null, null, null, null, null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + /* ~ morekeys_symbols_percent */ + /* label_go_key */ "Go", + /* label_send_key */ "Send", + /* label_next_key */ "Next", + /* label_done_key */ "Done", + /* label_search_key */ "Search", + /* label_previous_key */ "Prev", + /* label_pause_key */ "Pause", + /* label_wait_key */ "Wait", + }; + + /* Locale hr: Croatian */ + private static final String[] TEXTS_hr = { + /* morekeys_a ~ */ + null, null, null, null, null, null, + /* ~ morekeys_i */ + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + /* morekeys_c */ "\u010D,\u0107,\u00E7", + /* double_quotes */ "!text/double_9qm_rqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + /* morekeys_s */ "\u0161,\u015B,\u00DF", + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency */ null, + /* morekeys_y */ null, + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + /* morekeys_z */ "\u017E,\u017A,\u017C", + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + /* morekeys_d */ "\u0111", + /* morekeys_t ~ */ + null, null, null, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + }; + + /* Locale hu: Hungarian */ + private static final String[] TEXTS_hu = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E1,\u00E0,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F6,\u0151,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u0171,\u00FB,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EE,\u00EF,\u00EC,\u012F,\u012B", + /* morekeys_n */ null, + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_rqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + }; + + /* Locale hy_AM: Armenian (Armenia) */ + private static final String[] TEXTS_hy_AM = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0531: "Ա" ARMENIAN CAPITAL LETTER AYB + // U+0532: "Բ" ARMENIAN CAPITAL LETTER BEN + // U+0533: "Գ" ARMENIAN CAPITAL LETTER GIM + /* keylabel_to_alpha */ "\u0531\u0532\u0533", + /* morekeys_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, + /* ~ additional_morekeys_symbols_0 */ + /* morekeys_tablet_period */ "!text/morekeys_punctuation", + /* morekeys_nordic_row2_11 */ null, + // U+055E: "՞" ARMENIAN QUESTION MARK + // U+055C: "՜" ARMENIAN EXCLAMATION MARK + // U+055A: "՚" ARMENIAN APOSTROPHE + // U+0559: "ՙ" ARMENIAN MODIFIER LETTER LEFT HALF RING + // U+055D: "՝" ARMENIAN COMMA + // U+055B: "՛" ARMENIAN EMPHASIS MARK + // U+058A: "֊" ARMENIAN HYPHEN + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+055F: "՟" ARMENIAN ABBREVIATION MARK + /* morekeys_punctuation */ "!autoColumnOrder!8,\\,,\u055E,\u055C,.,\u055A,\u0559,?,!,\u055D,\u055B,\u058A,\u00BB,\u00AB,\u055F,;,:", + /* keyspec_tablet_comma */ "\u055D", + // U+0589: "։" ARMENIAN FULL STOP + /* keyspec_period */ "\u0589", + /* morekeys_period */ null, + /* keyspec_tablet_period */ "\u0589", + /* keyspec_swiss_row1_11 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, + /* ~ keyspec_right_single_angle_quote */ + // U+058F: "֏" ARMENIAN DRAM SIGN + // TODO: Enable this when we have glyph for the following letter + // <string name="keyspec_currency">֏</string> + // + // U+055D: "՝" ARMENIAN COMMA + /* keyspec_comma */ "\u055D", + /* morekeys_tablet_comma */ null, + /* keyhintlabel_period */ null, + // U+055E: "՞" ARMENIAN QUESTION MARK + // U+00BF: "¿" INVERTED QUESTION MARK + /* morekeys_question */ "\u055E,\u00BF", + /* morekeys_h ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, + /* ~ morekeys_greater_than */ + // U+055C: "՜" ARMENIAN EXCLAMATION MARK + // U+00A1: "¡" INVERTED EXCLAMATION MARK + /* morekeys_exclamation */ "\u055C,\u00A1", + }; + + /* Locale is: Icelandic */ + private static final String[] TEXTS_is = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E1,\u00E4,\u00E6,\u00E5,\u00E0,\u00E2,\u00E3,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F6,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00EB,\u00E8,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00FB,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EE,\u00EC,\u012F,\u012B", + /* morekeys_n */ null, + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + /* morekeys_z */ null, + // U+00F0: "ð" LATIN SMALL LETTER ETH + /* morekeys_d */ "\u00F0", + // U+00FE: "þ" LATIN SMALL LETTER THORN + /* morekeys_t */ "\u00FE", + }; + + /* Locale it: Italian */ + private static final String[] TEXTS_it = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E0,\u00E1,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101,\u00AA", + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F2,\u00F3,\u00F4,\u00F6,\u00F5,\u0153,\u00F8,\u014D,\u00BA", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E8,\u00E9,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00F9,\u00FA,\u00FB,\u00FC,\u016B", + /* keylabel_to_alpha */ null, + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00EC,\u00ED,\u00EE,\u00EF,\u012F,\u012B", + /* morekeys_n ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, + /* ~ keyspec_tablet_period */ + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + /* keyspec_swiss_row1_11 */ "\u00FC", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* keyspec_swiss_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* keyspec_swiss_row2_11 */ "\u00E4", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + /* morekeys_swiss_row1_11 */ "\u00E8", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* morekeys_swiss_row2_10 */ "\u00E9", + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + /* morekeys_swiss_row2_11 */ "\u00E0", + }; + + /* Locale iw: Hebrew */ + private static final String[] TEXTS_iw = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+05D0: "א" HEBREW LETTER ALEF + // U+05D1: "ב" HEBREW LETTER BET + // U+05D2: "ג" HEBREW LETTER GIMEL + /* keylabel_to_alpha */ "\u05D0\u05D1\u05D2", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_rqm_9qm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_rqm_9qm", + // U+20AA: "₪" NEW SHEQEL SIGN + /* keyspec_currency */ "\u20AA", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, + /* ~ morekeys_swiss_row2_11 */ + // U+2605: "★" BLACK STAR + /* morekeys_star */ "\u2605", + // The all letters need to be mirrored are found at + // http://www.unicode.org/Public/6.1.0/ucd/BidiMirroring.txt + // U+2264: "≤" LESS-THAN OR EQUAL TO + // U+2265: "≥" GREATER-THAN EQUAL TO + // U+00AB: "«" LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+00BB: "»" RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + // U+2039: "‹" SINGLE LEFT-POINTING ANGLE QUOTATION MARK + // U+203A: "›" SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + /* keyspec_left_parenthesis */ "(|)", + /* keyspec_right_parenthesis */ ")|(", + /* keyspec_left_square_bracket */ "[|]", + /* keyspec_right_square_bracket */ "]|[", + /* keyspec_left_curly_bracket */ "{|}", + /* keyspec_right_curly_bracket */ "}|{", + /* keyspec_less_than */ "<|>", + /* keyspec_greater_than */ ">|<", + /* keyspec_less_than_equal */ "\u2264|\u2265", + /* keyspec_greater_than_equal */ "\u2265|\u2264", + /* keyspec_left_double_angle_quote */ "\u00AB|\u00BB", + /* keyspec_right_double_angle_quote */ "\u00BB|\u00AB", + /* keyspec_left_single_angle_quote */ "\u2039|\u203A", + /* keyspec_right_single_angle_quote */ "\u203A|\u2039", + /* keyspec_comma ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + /* ~ morekeys_currency_dollar */ + // U+00B1: "±" PLUS-MINUS SIGN + // U+FB29: "﬩" HEBREW LETTER ALTERNATIVE PLUS SIGN + /* morekeys_plus */ "\u00B1,\uFB29", + }; + + /* Locale ka_GE: Georgian (Georgia) */ + private static final String[] TEXTS_ka_GE = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+10D0: "ა" GEORGIAN LETTER AN + // U+10D1: "ბ" GEORGIAN LETTER BAN + // U+10D2: "გ" GEORGIAN LETTER GAN + /* keylabel_to_alpha */ "\u10D0\u10D1\u10D2", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + }; + + /* Locale kk: Kazakh */ + private static final String[] TEXTS_kk = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, + /* ~ morekeys_k */ + // U+0451: "ё" CYRILLIC SMALL LETTER IO + /* morekeys_cyrillic_ie */ "\u0451", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, + /* ~ morekeys_nordic_row2_10 */ + // U+0449: "щ" CYRILLIC SMALL LETTER SHCHA + /* keyspec_east_slavic_row1_9 */ "\u0449", + // U+044B: "ы" CYRILLIC SMALL LETTER YERU + /* keyspec_east_slavic_row2_2 */ "\u044B", + // U+044D: "э" CYRILLIC SMALL LETTER E + /* keyspec_east_slavic_row2_11 */ "\u044D", + // U+0438: "и" CYRILLIC SMALL LETTER I + /* keyspec_east_slavic_row3_5 */ "\u0438", + // U+044A: "ъ" CYRILLIC SMALL LETTER HARD SIGN + /* morekeys_cyrillic_soft_sign */ "\u044A", + /* keyspec_symbols_1 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_w */ + // U+0456: "і" CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I + /* morekeys_east_slavic_row2_2 */ "\u0456", + // U+04AF: "ү" CYRILLIC SMALL LETTER STRAIGHT U + // U+04B1: "ұ" CYRILLIC SMALL LETTER STRAIGHT U WITH STROKE + /* morekeys_cyrillic_u */ "\u04AF,\u04B1", + // U+04A3: "ң" CYRILLIC SMALL LETTER EN WITH DESCENDER + /* morekeys_cyrillic_en */ "\u04A3", + // U+0493: "ғ" CYRILLIC SMALL LETTER GHE WITH STROKE + /* morekeys_cyrillic_ghe */ "\u0493", + // U+04E9: "ө" CYRILLIC SMALL LETTER BARRED O + /* morekeys_cyrillic_o */ "\u04E9", + /* morekeys_cyrillic_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + /* ~ keyspec_x */ + // U+04BB: "һ" CYRILLIC SMALL LETTER SHHA + /* morekeys_east_slavic_row2_11 */ "\u04BB", + // U+049B: "қ" CYRILLIC SMALL LETTER KA WITH DESCENDER + /* morekeys_cyrillic_ka */ "\u049B", + // U+04D9: "ә" CYRILLIC SMALL LETTER SCHWA + /* morekeys_cyrillic_a */ "\u04D9", + }; + + /* Locale km_KH: Khmer (Cambodia) */ + private static final String[] TEXTS_km_KH = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+1780: "ក" KHMER LETTER KA + // U+1781: "ខ" KHMER LETTER KHA + // U+1782: "គ" KHMER LETTER KO + /* keylabel_to_alpha */ "\u1780\u1781\u1782", + /* morekeys_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + /* ~ morekeys_cyrillic_a */ + // U+17DB: "៛" KHMER CURRENCY SYMBOL RIEL + /* morekeys_currency_dollar */ "\u17DB,\u00A2,\u00A3,\u20AC,\u00A5,\u20B1", + }; + + /* Locale kn_IN: Kannada (India) */ + private static final String[] TEXTS_kn_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0C85: "ಅ" KANNADA LETTER A + // U+0C86: "ಆ" KANNADA LETTER AA + // U+0C87: "ಇ" KANNADA LETTER I + /* keylabel_to_alpha */ "\u0C85\u0C86\u0C87", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + }; + + /* Locale ky: Kirghiz */ + private static final String[] TEXTS_ky = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, + /* ~ morekeys_k */ + // U+0451: "ё" CYRILLIC SMALL LETTER IO + /* morekeys_cyrillic_ie */ "\u0451", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, + /* ~ morekeys_nordic_row2_10 */ + // U+0449: "щ" CYRILLIC SMALL LETTER SHCHA + /* keyspec_east_slavic_row1_9 */ "\u0449", + // U+044B: "ы" CYRILLIC SMALL LETTER YERU + /* keyspec_east_slavic_row2_2 */ "\u044B", + // U+044D: "э" CYRILLIC SMALL LETTER E + /* keyspec_east_slavic_row2_11 */ "\u044D", + // U+0438: "и" CYRILLIC SMALL LETTER I + /* keyspec_east_slavic_row3_5 */ "\u0438", + // U+044A: "ъ" CYRILLIC SMALL LETTER HARD SIGN + /* morekeys_cyrillic_soft_sign */ "\u044A", + /* keyspec_symbols_1 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_east_slavic_row2_2 */ + // U+04AF: "ү" CYRILLIC SMALL LETTER STRAIGHT U + /* morekeys_cyrillic_u */ "\u04AF", + // U+04A3: "ң" CYRILLIC SMALL LETTER EN WITH DESCENDER + /* morekeys_cyrillic_en */ "\u04A3", + /* morekeys_cyrillic_ghe */ null, + // U+04E9: "ө" CYRILLIC SMALL LETTER BARRED O + /* morekeys_cyrillic_o */ "\u04E9", + }; + + /* Locale lo_LA: Lao (Laos) */ + private static final String[] TEXTS_lo_LA = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0E81: "ກ" LAO LETTER KO + // U+0E82: "ຂ" LAO LETTER KHO SUNG + // U+0E84: "ຄ" LAO LETTER KHO TAM + /* keylabel_to_alpha */ "\u0E81\u0E82\u0E84", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20AD: "₭" KIP SIGN + /* keyspec_currency */ "\u20AD", + }; + + /* Locale lt: Lithuanian */ + private static final String[] TEXTS_lt = { + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + /* morekeys_a */ "\u0105,\u00E4,\u0101,\u00E0,\u00E1,\u00E2,\u00E3,\u00E5,\u00E6", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_o */ "\u00F6,\u00F5,\u00F2,\u00F3,\u00F4,\u0153,\u0151,\u00F8", + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + /* morekeys_e */ "\u0117,\u0119,\u0113,\u00E8,\u00E9,\u00EA,\u00EB,\u011B", + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + /* morekeys_u */ "\u016B,\u0173,\u00FC,\u016B,\u00F9,\u00FA,\u00FB,\u016F,\u0171", + /* keylabel_to_alpha */ null, + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + /* morekeys_i */ "\u012F,\u012B,\u00EC,\u00ED,\u00EE,\u00EF,\u0131", + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u0146,\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u00E7,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + /* morekeys_s */ "\u0161,\u00DF,\u015B,\u015F", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + /* morekeys_z */ "\u017E,\u017C,\u017A", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u010F", + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + /* morekeys_t */ "\u0163,\u0165", + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + /* morekeys_l */ "\u013C,\u0142,\u013A,\u013E", + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u0123,\u011F", + /* single_angle_quotes */ null, + /* double_angle_quotes */ null, + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + /* morekeys_r */ "\u0157,\u0159,\u0155", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + /* morekeys_k */ "\u0137", + }; + + /* Locale lv: Latvian */ + private static final String[] TEXTS_lv = { + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + /* morekeys_a */ "\u0101,\u00E0,\u00E1,\u00E2,\u00E3,\u00E4,\u00E5,\u00E6,\u0105", + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_o */ "\u00F2,\u00F3,\u00F4,\u00F5,\u00F6,\u0153,\u0151,\u00F8", + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + /* morekeys_e */ "\u0113,\u0117,\u00E8,\u00E9,\u00EA,\u00EB,\u0119,\u011B", + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + /* morekeys_u */ "\u016B,\u0173,\u00F9,\u00FA,\u00FB,\u00FC,\u016F,\u0171", + /* keylabel_to_alpha */ null, + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + /* morekeys_i */ "\u012B,\u012F,\u00EC,\u00ED,\u00EE,\u00EF,\u0131", + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u0146,\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u00E7,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + /* morekeys_s */ "\u0161,\u00DF,\u015B,\u015F", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + /* morekeys_z */ "\u017E,\u017C,\u017A", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u010F", + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + /* morekeys_t */ "\u0163,\u0165", + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + /* morekeys_l */ "\u013C,\u0142,\u013A,\u013E", + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u0123,\u011F", + /* single_angle_quotes */ null, + /* double_angle_quotes */ null, + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + /* morekeys_r */ "\u0157,\u0159,\u0155", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + /* morekeys_k */ "\u0137", + }; + + /* Locale mk: Macedonian */ + private static final String[] TEXTS_mk = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_k */ + // U+0450: "ѐ" CYRILLIC SMALL LETTER IE WITH GRAVE + /* morekeys_cyrillic_ie */ "\u0450", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, + /* ~ morekeys_cyrillic_o */ + // U+045D: "ѝ" CYRILLIC SMALL LETTER I WITH GRAVE + /* morekeys_cyrillic_i */ "\u045D", + // U+0455: "ѕ" CYRILLIC SMALL LETTER DZE + /* keyspec_south_slavic_row1_6 */ "\u0455", + // U+045C: "ќ" CYRILLIC SMALL LETTER KJE + /* keyspec_south_slavic_row2_11 */ "\u045C", + // U+0437: "з" CYRILLIC SMALL LETTER ZE + /* keyspec_south_slavic_row3_1 */ "\u0437", + // U+0453: "ѓ" CYRILLIC SMALL LETTER GJE + /* keyspec_south_slavic_row3_8 */ "\u0453", + }; + + /* Locale ml_IN: Malayalam (India) */ + private static final String[] TEXTS_ml_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0D05: "അ" MALAYALAM LETTER A + /* keylabel_to_alpha */ "\u0D05", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + }; + + /* Locale mn_MN: Mongolian (Mongolia) */ + private static final String[] TEXTS_mn_MN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20AE: "₮" TUGRIK SIGN + /* keyspec_currency */ "\u20AE", + }; + + /* Locale mr_IN: Marathi (India) */ + private static final String[] TEXTS_mr_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0915: "क" DEVANAGARI LETTER KA + // U+0916: "ख" DEVANAGARI LETTER KHA + // U+0917: "ग" DEVANAGARI LETTER GA + /* keylabel_to_alpha */ "\u0915\u0916\u0917", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, + /* ~ morekeys_cyrillic_soft_sign */ + // U+0967: "१" DEVANAGARI DIGIT ONE + /* keyspec_symbols_1 */ "\u0967", + // U+0968: "२" DEVANAGARI DIGIT TWO + /* keyspec_symbols_2 */ "\u0968", + // U+0969: "३" DEVANAGARI DIGIT THREE + /* keyspec_symbols_3 */ "\u0969", + // U+096A: "४" DEVANAGARI DIGIT FOUR + /* keyspec_symbols_4 */ "\u096A", + // U+096B: "५" DEVANAGARI DIGIT FIVE + /* keyspec_symbols_5 */ "\u096B", + // U+096C: "६" DEVANAGARI DIGIT SIX + /* keyspec_symbols_6 */ "\u096C", + // U+096D: "७" DEVANAGARI DIGIT SEVEN + /* keyspec_symbols_7 */ "\u096D", + // U+096E: "८" DEVANAGARI DIGIT EIGHT + /* keyspec_symbols_8 */ "\u096E", + // U+096F: "९" DEVANAGARI DIGIT NINE + /* keyspec_symbols_9 */ "\u096F", + // U+0966: "०" DEVANAGARI DIGIT ZERO + /* keyspec_symbols_0 */ "\u0966", + // Label for "switch to symbols" key. + /* keylabel_to_symbol */ "?\u0967\u0968\u0969", + /* additional_morekeys_symbols_1 */ "1", + /* additional_morekeys_symbols_2 */ "2", + /* additional_morekeys_symbols_3 */ "3", + /* additional_morekeys_symbols_4 */ "4", + /* additional_morekeys_symbols_5 */ "5", + /* additional_morekeys_symbols_6 */ "6", + /* additional_morekeys_symbols_7 */ "7", + /* additional_morekeys_symbols_8 */ "8", + /* additional_morekeys_symbols_9 */ "9", + /* additional_morekeys_symbols_0 */ "0", + }; + + /* Locale nb: Norwegian Bokmål */ + private static final String[] TEXTS_nb = { + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E5,\u00E6,\u00E4,\u00E0,\u00E1,\u00E2,\u00E3,\u0101", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F8,\u00F6,\u00F4,\u00F2,\u00F3,\u00F5,\u0153,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0119,\u0117,\u0113", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,\u00FB,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha ~ */ + null, null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_rqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_cyrillic_ie */ + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + /* keyspec_nordic_row1_11 */ "\u00E5", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* keyspec_nordic_row2_10 */ "\u00F8", + // U+00E6: "æ" LATIN SMALL LETTER AE + /* keyspec_nordic_row2_11 */ "\u00E6", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* morekeys_nordic_row2_10 */ "\u00F6", + /* keyspec_east_slavic_row1_9 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_tablet_period */ + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* morekeys_nordic_row2_11 */ "\u00E4", + }; + + /* Locale ne_NP: Nepali (Nepal) */ + private static final String[] TEXTS_ne_NP = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0915: "क" DEVANAGARI LETTER KA + // U+0916: "ख" DEVANAGARI LETTER KHA + // U+0917: "ग" DEVANAGARI LETTER GA + /* keylabel_to_alpha */ "\u0915\u0916\u0917", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+0930/U+0941/U+002E "रु." NEPALESE RUPEE SIGN + /* keyspec_currency */ "\u0930\u0941.", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, + /* ~ morekeys_cyrillic_soft_sign */ + // U+0967: "१" DEVANAGARI DIGIT ONE + /* keyspec_symbols_1 */ "\u0967", + // U+0968: "२" DEVANAGARI DIGIT TWO + /* keyspec_symbols_2 */ "\u0968", + // U+0969: "३" DEVANAGARI DIGIT THREE + /* keyspec_symbols_3 */ "\u0969", + // U+096A: "४" DEVANAGARI DIGIT FOUR + /* keyspec_symbols_4 */ "\u096A", + // U+096B: "५" DEVANAGARI DIGIT FIVE + /* keyspec_symbols_5 */ "\u096B", + // U+096C: "६" DEVANAGARI DIGIT SIX + /* keyspec_symbols_6 */ "\u096C", + // U+096D: "७" DEVANAGARI DIGIT SEVEN + /* keyspec_symbols_7 */ "\u096D", + // U+096E: "८" DEVANAGARI DIGIT EIGHT + /* keyspec_symbols_8 */ "\u096E", + // U+096F: "९" DEVANAGARI DIGIT NINE + /* keyspec_symbols_9 */ "\u096F", + // U+0966: "०" DEVANAGARI DIGIT ZERO + /* keyspec_symbols_0 */ "\u0966", + // Label for "switch to symbols" key. + /* keylabel_to_symbol */ "?\u0967\u0968\u0969", + /* additional_morekeys_symbols_1 */ "1", + /* additional_morekeys_symbols_2 */ "2", + /* additional_morekeys_symbols_3 */ "3", + /* additional_morekeys_symbols_4 */ "4", + /* additional_morekeys_symbols_5 */ "5", + /* additional_morekeys_symbols_6 */ "6", + /* additional_morekeys_symbols_7 */ "7", + /* additional_morekeys_symbols_8 */ "8", + /* additional_morekeys_symbols_9 */ "9", + /* additional_morekeys_symbols_0 */ "0", + /* morekeys_tablet_period */ "!autoColumnOrder!8,.,\\,,',#,),(,/,;,@,:,-,\",+,\\%,&", + /* morekeys_nordic_row2_11 ~ */ + null, null, null, + /* ~ keyspec_tablet_comma */ + // U+0964: "।" DEVANAGARI DANDA + /* keyspec_period */ "\u0964", + /* morekeys_period */ "!autoColumnOrder!9,.,\\,,?,!,#,),(,/,;,',@,:,-,\",+,\\%,&", + /* keyspec_tablet_period */ "\u0964", + }; + + /* Locale nl: Dutch */ + private static final String[] TEXTS_nl = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E1,\u00E4,\u00E2,\u00E0,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F6,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00EB,\u00EA,\u00E8,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00FB,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B,\u0133", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_rqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency */ null, + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_y */ "\u0133", + }; + + /* Locale pl: Polish */ + private static final String[] TEXTS_pl = { + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u0105,\u00E1,\u00E0,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F3,\u00F6,\u00F4,\u00F2,\u00F5,\u0153,\u00F8,\u014D", + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u0119,\u00E8,\u00E9,\u00EA,\u00EB,\u0117,\u0113", + /* morekeys_u ~ */ + null, null, null, + /* ~ morekeys_i */ + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u0144,\u00F1", + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u0107,\u00E7,\u010D", + /* double_quotes */ "!text/double_9qm_rqm", + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u015B,\u00DF,\u0161", + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency */ null, + /* morekeys_y */ null, + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017C,\u017A,\u017E", + /* morekeys_d */ null, + /* morekeys_t */ null, + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u0142", + }; + + /* Locale pt: Portuguese */ + private static final String[] TEXTS_pt = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E3,\u00E0,\u00E2,\u00E4,\u00E5,\u00E6,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F5,\u00F4,\u00F2,\u00F6,\u0153,\u00F8,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + /* morekeys_e */ "\u00E9,\u00EA,\u00E8,\u0119,\u0117,\u0113,\u00EB", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EE,\u00EC,\u00EF,\u012F,\u012B", + /* morekeys_n */ null, + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u00E7,\u010D,\u0107", + }; + + /* Locale rm: Raeto-Romance */ + private static final String[] TEXTS_rm = { + /* morekeys_a */ null, + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_o */ "\u00F2,\u00F3,\u00F6,\u00F4,\u00F5,\u0153,\u00F8", + }; + + /* Locale ro: Romanian */ + private static final String[] TEXTS_ro = { + // U+0103: "ă" LATIN SMALL LETTER A WITH BREVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u0103,\u00E2,\u00E3,\u00E0,\u00E1,\u00E4,\u00E6,\u00E5,\u0101", + /* morekeys_o ~ */ + null, null, null, null, + /* ~ keylabel_to_alpha */ + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00EE,\u00EF,\u00EC,\u00ED,\u012F,\u012B", + /* morekeys_n */ null, + /* morekeys_c */ null, + /* double_quotes */ "!text/double_9qm_rqm", + // U+0219: "ș" LATIN SMALL LETTER S WITH COMMA BELOW + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u0219,\u00DF,\u015B,\u0161", + /* single_quotes */ "!text/single_9qm_rqm", + /* keyspec_currency ~ */ + null, null, null, null, + /* ~ morekeys_d */ + // U+021B: "ț" LATIN SMALL LETTER T WITH COMMA BELOW + /* morekeys_t */ "\u021B", + }; + + /* Locale ru: Russian */ + private static final String[] TEXTS_ru = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_k */ + // U+0451: "ё" CYRILLIC SMALL LETTER IO + /* morekeys_cyrillic_ie */ "\u0451", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, + /* ~ morekeys_nordic_row2_10 */ + // U+0449: "щ" CYRILLIC SMALL LETTER SHCHA + /* keyspec_east_slavic_row1_9 */ "\u0449", + // U+044B: "ы" CYRILLIC SMALL LETTER YERU + /* keyspec_east_slavic_row2_2 */ "\u044B", + // U+044D: "э" CYRILLIC SMALL LETTER E + /* keyspec_east_slavic_row2_11 */ "\u044D", + // U+0438: "и" CYRILLIC SMALL LETTER I + /* keyspec_east_slavic_row3_5 */ "\u0438", + // U+044A: "ъ" CYRILLIC SMALL LETTER HARD SIGN + /* morekeys_cyrillic_soft_sign */ "\u044A", + }; + + /* Locale si_LK: Sinhalese (Sri Lanka) */ + private static final String[] TEXTS_si_LK = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0D85: "අ" SINHALA LETTER AYANNA + // U+0D86: "ආ" SINHALA LETTER AAYANNA + /* keylabel_to_alpha */ "\u0D85,\u0D86", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+0DBB/U+0DD4: "රු" SINHALA LETTER RAYANNA/SINHALA VOWEL SIGN KETTI PAA-PILLA + /* keyspec_currency */ "\u0DBB\u0DD4", + }; + + /* Locale sk: Slovak */ + private static final String[] TEXTS_sk = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + /* morekeys_a */ "\u00E1,\u00E4,\u0101,\u00E0,\u00E2,\u00E3,\u00E5,\u00E6,\u0105", + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + /* morekeys_o */ "\u00F4,\u00F3,\u00F6,\u00F2,\u00F5,\u0153,\u0151,\u00F8", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + /* morekeys_e */ "\u00E9,\u011B,\u0113,\u0117,\u00E8,\u00EA,\u00EB,\u0119", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + /* morekeys_u */ "\u00FA,\u016F,\u00FC,\u016B,\u0173,\u00F9,\u00FB,\u0171", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + /* morekeys_i */ "\u00ED,\u012B,\u012F,\u00EC,\u00EE,\u00EF,\u0131", + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u0148,\u0146,\u00F1,\u0144", + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u00E7,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + /* morekeys_s */ "\u0161,\u00DF,\u015B,\u015F", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + /* morekeys_z */ "\u017E,\u017C,\u017A", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u010F", + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + /* morekeys_t */ "\u0165,\u0163", + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u013E,\u013A,\u013C,\u0142", + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u0123,\u011F", + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + /* morekeys_r */ "\u0155,\u0159,\u0157", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + /* morekeys_k */ "\u0137", + }; + + /* Locale sl: Slovenian */ + private static final String[] TEXTS_sl = { + /* morekeys_a ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_n */ + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u0107", + /* double_quotes */ "!text/double_9qm_lqm", + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u0161", + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency */ null, + /* morekeys_y */ null, + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017E", + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + /* morekeys_d */ "\u0111", + /* morekeys_t ~ */ + null, null, null, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + }; + + /* Locale sr: Serbian */ + private static final String[] TEXTS_sr = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // END: More keys definitions for Serbian (Cyrillic) + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + /* keyspec_currency ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_g */ + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + /* morekeys_r */ null, + /* morekeys_k */ null, + // U+0450: "ѐ" CYRILLIC SMALL LETTER IE WITH GRAVE + /* morekeys_cyrillic_ie */ "\u0450", + /* keyspec_nordic_row1_11 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, + /* ~ morekeys_cyrillic_o */ + // U+045D: "ѝ" CYRILLIC SMALL LETTER I WITH GRAVE + /* morekeys_cyrillic_i */ "\u045D", + // TODO: Move these to sr-Latn once we can handle IETF language tag with script name specified. + // BEGIN: More keys definitions for Serbian (Latin) + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // <string name="morekeys_s">š,ß,ś</string> + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // <string name="morekeys_c">č,ç,ć</string> + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + // <string name="morekeys_d">ď</string> + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // <string name="morekeys_z">ž,ź,ż</string> + // END: More keys definitions for Serbian (Latin) + // BEGIN: More keys definitions for Serbian (Cyrillic) + // U+0437: "з" CYRILLIC SMALL LETTER ZE + /* keyspec_south_slavic_row1_6 */ "\u0437", + // U+045B: "ћ" CYRILLIC SMALL LETTER TSHE + /* keyspec_south_slavic_row2_11 */ "\u045B", + // U+0455: "ѕ" CYRILLIC SMALL LETTER DZE + /* keyspec_south_slavic_row3_1 */ "\u0455", + // U+0452: "ђ" CYRILLIC SMALL LETTER DJE + /* keyspec_south_slavic_row3_8 */ "\u0452", + }; + + /* Locale sr_ZZ: Serbian (ZZ) */ + private static final String[] TEXTS_sr_ZZ = { + /* morekeys_a */ null, + /* morekeys_o */ null, + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + /* morekeys_e */ "\u00E8", + /* morekeys_u */ null, + /* keylabel_to_alpha */ null, + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + /* morekeys_i */ "\u00EC", + /* morekeys_n */ null, + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + /* morekeys_c */ "\u010D,\u0107,%", + /* double_quotes */ null, + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u0161,%", + /* single_quotes ~ */ + null, null, null, + /* ~ morekeys_y */ + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017E,%", + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + /* morekeys_d */ "\u0111,%", + /* morekeys_t ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, + /* ~ morekeys_symbols_percent */ + /* label_go_key */ "Idi", + /* label_send_key */ "\u0160alji", + /* label_next_key */ "Sled", + /* label_done_key */ "Gotov", + /* label_search_key */ "Tra\u017Ei", + /* label_previous_key */ "Preth", + /* label_pause_key */ "Pauza", + /* label_wait_key */ "\u010Cekaj", + }; + + /* Locale sv: Swedish */ + private static final String[] TEXTS_sv = { + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E5: "å" LATIN SMALL LETTER A WITH RING + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + /* morekeys_a */ "\u00E4,\u00E5,\u00E6,\u00E1,\u00E0,\u00E2,\u0105,\u00E3", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,\u00F8,\u0153,\u00F3,\u00F2,\u00F4,\u00F5,\u014D", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0119", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,\u00FA,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + /* morekeys_i */ "\u00ED,\u00EC,\u00EE,\u00EF", + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + /* morekeys_n */ "\u0144,\u00F1,\u0148", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes */ null, + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + /* morekeys_s */ "\u015B,\u0161,\u015F,\u00DF", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + /* morekeys_y */ "\u00FD,\u00FF", + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + /* morekeys_z */ "\u017A,\u017E,\u017C", + // U+00F0: "ð" LATIN SMALL LETTER ETH + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + /* morekeys_d */ "\u00F0,\u010F", + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + // U+00FE: "þ" LATIN SMALL LETTER THORN + /* morekeys_t */ "\u0165,\u00FE", + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u0142", + /* morekeys_g */ null, + /* single_angle_quotes */ "!text/single_raqm_laqm", + /* double_angle_quotes */ "!text/double_raqm_laqm", + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + /* morekeys_r */ "\u0159", + /* morekeys_k */ null, + /* morekeys_cyrillic_ie */ null, + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + /* keyspec_nordic_row1_11 */ "\u00E5", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + /* keyspec_nordic_row2_10 */ "\u00F6", + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + /* keyspec_nordic_row2_11 */ "\u00E4", + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + /* morekeys_nordic_row2_10 */ "\u00F8,\u0153", + /* keyspec_east_slavic_row1_9 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_tablet_period */ + // U+00E6: "æ" LATIN SMALL LETTER AE + /* morekeys_nordic_row2_11 */ "\u00E6", + }; + + /* Locale sw: Swahili */ + private static final String[] TEXTS_sw = { + // This is the same as English except morekeys_g. + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E0,\u00E1,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + /* morekeys_o */ "\u00F4,\u00F6,\u00F2,\u00F3,\u0153,\u00F8,\u014D,\u00F5", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E8,\u00E9,\u00EA,\u00EB,\u0113", + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FB,\u00FC,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + /* morekeys_i */ "\u00EE,\u00EF,\u00ED,\u012B,\u00EC", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + /* morekeys_c */ "\u00E7", + /* double_quotes */ null, + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + /* morekeys_s */ "\u00DF", + /* single_quotes ~ */ + null, null, null, null, null, null, null, + /* ~ morekeys_l */ + /* morekeys_g */ "g\'", + }; + + /* Locale ta_IN: Tamil (India) */ + private static final String[] TEXTS_ta_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0BA4: "த" TAMIL LETTER TA + // U+0BAE/U+0BBF: "மி" TAMIL LETTER MA/TAMIL VOWEL SIGN I + // U+0BB4/U+0BCD: "ழ்" TAMIL LETTER LLLA/TAMIL SIGN VIRAMA + /* keylabel_to_alpha */ "\u0BA4\u0BAE\u0BBF\u0BB4\u0BCD", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+0BF9: "௹" TAMIL RUPEE SIGN + /* keyspec_currency */ "\u0BF9", + }; + + /* Locale ta_LK: Tamil (Sri Lanka) */ + private static final String[] TEXTS_ta_LK = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0BA4: "த" TAMIL LETTER TA + // U+0BAE/U+0BBF: "மி" TAMIL LETTER MA/TAMIL VOWEL SIGN I + // U+0BB4/U+0BCD: "ழ்" TAMIL LETTER LLLA/TAMIL SIGN VIRAMA + /* keylabel_to_alpha */ "\u0BA4\u0BAE\u0BBF\u0BB4\u0BCD", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+0DBB/U+0DD4: "රු" SINHALA LETTER RAYANNA/SINHALA VOWEL SIGN KETTI PAA-PILLA + /* keyspec_currency */ "\u0DBB\u0DD4", + }; + + /* Locale ta_SG: Tamil (Singapore) */ + private static final String[] TEXTS_ta_SG = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0BA4: "த" TAMIL LETTER TA + // U+0BAE/U+0BBF: "மி" TAMIL LETTER MA/TAMIL VOWEL SIGN I + // U+0BB4/U+0BCD: "ழ்" TAMIL LETTER LLLA/TAMIL SIGN VIRAMA + /* keylabel_to_alpha */ "\u0BA4\u0BAE\u0BBF\u0BB4\u0BCD", + }; + + /* Locale te_IN: Telugu (India) */ + private static final String[] TEXTS_te_IN = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0C05: "అ" TELUGU LETTER A + // U+0C06: "ఆ" TELUGU LETTER AA + // U+0C07: "ఇ" TELUGU LETTER I + /* keylabel_to_alpha */ "\u0C05\u0C06\u0C07", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+20B9: "₹" INDIAN RUPEE SIGN + /* keyspec_currency */ "\u20B9", + }; + + /* Locale th: Thai */ + private static final String[] TEXTS_th = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0E01: "ก" THAI CHARACTER KO KAI + // U+0E02: "ข" THAI CHARACTER KHO KHAI + // U+0E04: "ค" THAI CHARACTER KHO KHWAI + /* keylabel_to_alpha */ "\u0E01\u0E02\u0E04", + /* morekeys_i ~ */ + null, null, null, null, null, null, + /* ~ single_quotes */ + // U+0E3F: "฿" THAI CURRENCY SYMBOL BAHT + /* keyspec_currency */ "\u0E3F", + }; + + /* Locale tl: Tagalog */ + private static final String[] TEXTS_tl = { + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E1,\u00E0,\u00E4,\u00E2,\u00E3,\u00E5,\u0105,\u00E6,\u0101,\u00AA", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F3,\u00F2,\u00F6,\u00F4,\u00F5,\u00F8,\u0153,\u014D,\u00BA", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EB,\u00EA,\u0119,\u0117,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FC,\u00F9,\u00FB,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u00ED,\u00EF,\u00EC,\u00EE,\u012F,\u012B", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + /* morekeys_n */ "\u00F1,\u0144", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + }; + + /* Locale tr: Turkish */ + private static final String[] TEXTS_tr = { + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + /* morekeys_a */ "\u00E2,\u00E4,\u00E1", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,\u00F4,\u0153,\u00F2,\u00F3,\u00F5,\u00F8,\u014D", + // U+0259: "ə" LATIN SMALL LETTER SCHWA + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* morekeys_e */ "\u0259,\u00E9", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,\u00FB,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u0131,\u00EE,\u00EF,\u00EC,\u00ED,\u012F,\u012B", + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u0148,\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes */ null, + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u015F,\u00DF,\u015B,\u0161", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + /* morekeys_y */ "\u00FD", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017E", + /* morekeys_d ~ */ + null, null, null, + /* ~ morekeys_l */ + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u011F", + }; + + /* Locale uk: Ukrainian */ + private static final String[] TEXTS_uk = { + /* morekeys_a ~ */ + null, null, null, null, + /* ~ morekeys_u */ + // Label for "switch to alphabetic" key. + // U+0410: "А" CYRILLIC CAPITAL LETTER A + // U+0411: "Б" CYRILLIC CAPITAL LETTER BE + // U+0412: "В" CYRILLIC CAPITAL LETTER VE + /* keylabel_to_alpha */ "\u0410\u0411\u0412", + /* morekeys_i ~ */ + null, null, null, + /* ~ morekeys_c */ + /* double_quotes */ "!text/double_9qm_lqm", + /* morekeys_s */ null, + /* single_quotes */ "!text/single_9qm_lqm", + // U+20B4: "₴" HRYVNIA SIGN + /* keyspec_currency */ "\u20B4", + /* morekeys_y ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_nordic_row2_10 */ + // U+0449: "щ" CYRILLIC SMALL LETTER SHCHA + /* keyspec_east_slavic_row1_9 */ "\u0449", + // U+0456: "і" CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I + /* keyspec_east_slavic_row2_2 */ "\u0456", + // U+0454: "є" CYRILLIC SMALL LETTER UKRAINIAN IE + /* keyspec_east_slavic_row2_11 */ "\u0454", + // U+0438: "и" CYRILLIC SMALL LETTER I + /* keyspec_east_slavic_row3_5 */ "\u0438", + // U+044A: "ъ" CYRILLIC SMALL LETTER HARD SIGN + /* morekeys_cyrillic_soft_sign */ "\u044A", + /* keyspec_symbols_1 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + /* ~ morekeys_w */ + // U+0457: "ї" CYRILLIC SMALL LETTER YI + /* morekeys_east_slavic_row2_2 */ "\u0457", + /* morekeys_cyrillic_u */ null, + /* morekeys_cyrillic_en */ null, + // U+0491: "ґ" CYRILLIC SMALL LETTER GHE WITH UPTURN + /* morekeys_cyrillic_ghe */ "\u0491", + }; + + /* Locale uz_UZ: Uzbek (Uzbekistan) */ + private static final String[] TEXTS_uz_UZ = { + // This is the same as Turkish + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + /* morekeys_a */ "\u00E2,\u00E4,\u00E1", + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + /* morekeys_o */ "\u00F6,\u00F4,\u0153,\u00F2,\u00F3,\u00F5,\u00F8,\u014D", + // U+0259: "ə" LATIN SMALL LETTER SCHWA + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + /* morekeys_e */ "\u0259,\u00E9", + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FC,\u00FB,\u00F9,\u00FA,\u016B", + /* keylabel_to_alpha */ null, + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + /* morekeys_i */ "\u0131,\u00EE,\u00EF,\u00EC,\u00ED,\u012F,\u012B", + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u0148,\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u010D", + /* double_quotes */ null, + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + /* morekeys_s */ "\u015F,\u00DF,\u015B,\u0161", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + /* morekeys_y */ "\u00FD", + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017E", + /* morekeys_d ~ */ + null, null, null, + /* ~ morekeys_l */ + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + /* morekeys_g */ "\u011F", + }; + + /* Locale vi: Vietnamese */ + private static final String[] TEXTS_vi = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+1EA3: "ả" LATIN SMALL LETTER A WITH HOOK ABOVE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+1EA1: "ạ" LATIN SMALL LETTER A WITH DOT BELOW + // U+0103: "ă" LATIN SMALL LETTER A WITH BREVE + // U+1EB1: "ằ" LATIN SMALL LETTER A WITH BREVE AND GRAVE + // U+1EAF: "ắ" LATIN SMALL LETTER A WITH BREVE AND ACUTE + // U+1EB3: "ẳ" LATIN SMALL LETTER A WITH BREVE AND HOOK ABOVE + // U+1EB5: "ẵ" LATIN SMALL LETTER A WITH BREVE AND TILDE + // U+1EB7: "ặ" LATIN SMALL LETTER A WITH BREVE AND DOT BELOW + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+1EA7: "ầ" LATIN SMALL LETTER A WITH CIRCUMFLEX AND GRAVE + // U+1EA5: "ấ" LATIN SMALL LETTER A WITH CIRCUMFLEX AND ACUTE + // U+1EA9: "ẩ" LATIN SMALL LETTER A WITH CIRCUMFLEX AND HOOK ABOVE + // U+1EAB: "ẫ" LATIN SMALL LETTER A WITH CIRCUMFLEX AND TILDE + // U+1EAD: "ậ" LATIN SMALL LETTER A WITH CIRCUMFLEX AND DOT BELOW + /* morekeys_a */ "\u00E0,\u00E1,\u1EA3,\u00E3,\u1EA1,\u0103,\u1EB1,\u1EAF,\u1EB3,\u1EB5,\u1EB7,\u00E2,\u1EA7,\u1EA5,\u1EA9,\u1EAB,\u1EAD", + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+1ECF: "ỏ" LATIN SMALL LETTER O WITH HOOK ABOVE + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+1ECD: "ọ" LATIN SMALL LETTER O WITH DOT BELOW + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+1ED3: "ồ" LATIN SMALL LETTER O WITH CIRCUMFLEX AND GRAVE + // U+1ED1: "ố" LATIN SMALL LETTER O WITH CIRCUMFLEX AND ACUTE + // U+1ED5: "ổ" LATIN SMALL LETTER O WITH CIRCUMFLEX AND HOOK ABOVE + // U+1ED7: "ỗ" LATIN SMALL LETTER O WITH CIRCUMFLEX AND TILDE + // U+1ED9: "ộ" LATIN SMALL LETTER O WITH CIRCUMFLEX AND DOT BELOW + // U+01A1: "ơ" LATIN SMALL LETTER O WITH HORN + // U+1EDD: "ờ" LATIN SMALL LETTER O WITH HORN AND GRAVE + // U+1EDB: "ớ" LATIN SMALL LETTER O WITH HORN AND ACUTE + // U+1EDF: "ở" LATIN SMALL LETTER O WITH HORN AND HOOK ABOVE + // U+1EE1: "ỡ" LATIN SMALL LETTER O WITH HORN AND TILDE + // U+1EE3: "ợ" LATIN SMALL LETTER O WITH HORN AND DOT BELOW + /* morekeys_o */ "\u00F2,\u00F3,\u1ECF,\u00F5,\u1ECD,\u00F4,\u1ED3,\u1ED1,\u1ED5,\u1ED7,\u1ED9,\u01A1,\u1EDD,\u1EDB,\u1EDF,\u1EE1,\u1EE3", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+1EBB: "ẻ" LATIN SMALL LETTER E WITH HOOK ABOVE + // U+1EBD: "ẽ" LATIN SMALL LETTER E WITH TILDE + // U+1EB9: "ẹ" LATIN SMALL LETTER E WITH DOT BELOW + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+1EC1: "ề" LATIN SMALL LETTER E WITH CIRCUMFLEX AND GRAVE + // U+1EBF: "ế" LATIN SMALL LETTER E WITH CIRCUMFLEX AND ACUTE + // U+1EC3: "ể" LATIN SMALL LETTER E WITH CIRCUMFLEX AND HOOK ABOVE + // U+1EC5: "ễ" LATIN SMALL LETTER E WITH CIRCUMFLEX AND TILDE + // U+1EC7: "ệ" LATIN SMALL LETTER E WITH CIRCUMFLEX AND DOT BELOW + /* morekeys_e */ "\u00E8,\u00E9,\u1EBB,\u1EBD,\u1EB9,\u00EA,\u1EC1,\u1EBF,\u1EC3,\u1EC5,\u1EC7", + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+1EE7: "ủ" LATIN SMALL LETTER U WITH HOOK ABOVE + // U+0169: "ũ" LATIN SMALL LETTER U WITH TILDE + // U+1EE5: "ụ" LATIN SMALL LETTER U WITH DOT BELOW + // U+01B0: "ư" LATIN SMALL LETTER U WITH HORN + // U+1EEB: "ừ" LATIN SMALL LETTER U WITH HORN AND GRAVE + // U+1EE9: "ứ" LATIN SMALL LETTER U WITH HORN AND ACUTE + // U+1EED: "ử" LATIN SMALL LETTER U WITH HORN AND HOOK ABOVE + // U+1EEF: "ữ" LATIN SMALL LETTER U WITH HORN AND TILDE + // U+1EF1: "ự" LATIN SMALL LETTER U WITH HORN AND DOT BELOW + /* morekeys_u */ "\u00F9,\u00FA,\u1EE7,\u0169,\u1EE5,\u01B0,\u1EEB,\u1EE9,\u1EED,\u1EEF,\u1EF1", + /* keylabel_to_alpha */ null, + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+1EC9: "ỉ" LATIN SMALL LETTER I WITH HOOK ABOVE + // U+0129: "ĩ" LATIN SMALL LETTER I WITH TILDE + // U+1ECB: "ị" LATIN SMALL LETTER I WITH DOT BELOW + /* morekeys_i */ "\u00EC,\u00ED,\u1EC9,\u0129,\u1ECB", + /* morekeys_n ~ */ + null, null, null, null, null, + /* ~ single_quotes */ + // U+20AB: "₫" DONG SIGN + /* keyspec_currency */ "\u20AB", + // U+1EF3: "ỳ" LATIN SMALL LETTER Y WITH GRAVE + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+1EF7: "ỷ" LATIN SMALL LETTER Y WITH HOOK ABOVE + // U+1EF9: "ỹ" LATIN SMALL LETTER Y WITH TILDE + // U+1EF5: "ỵ" LATIN SMALL LETTER Y WITH DOT BELOW + /* morekeys_y */ "\u1EF3,\u00FD,\u1EF7,\u1EF9,\u1EF5", + /* morekeys_z */ null, + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + /* morekeys_d */ "\u0111", + }; + + /* Locale zu: Zulu */ + private static final String[] TEXTS_zu = { + // This is the same as English + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + /* morekeys_a */ "\u00E0,\u00E1,\u00E2,\u00E4,\u00E6,\u00E3,\u00E5,\u0101", + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + /* morekeys_o */ "\u00F3,\u00F4,\u00F6,\u00F2,\u0153,\u00F8,\u014D,\u00F5", + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + /* morekeys_e */ "\u00E9,\u00E8,\u00EA,\u00EB,\u0113", + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + /* morekeys_u */ "\u00FA,\u00FB,\u00FC,\u00F9,\u016B", + /* keylabel_to_alpha */ null, + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + /* morekeys_i */ "\u00ED,\u00EE,\u00EF,\u012B,\u00EC", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + /* morekeys_n */ "\u00F1", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + /* morekeys_c */ "\u00E7", + /* double_quotes */ null, + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + /* morekeys_s */ "\u00DF", + }; + + /* Locale zz: Alphabet */ + private static final String[] TEXTS_zz = { + // U+00E0: "à" LATIN SMALL LETTER A WITH GRAVE + // U+00E1: "á" LATIN SMALL LETTER A WITH ACUTE + // U+00E2: "â" LATIN SMALL LETTER A WITH CIRCUMFLEX + // U+00E3: "ã" LATIN SMALL LETTER A WITH TILDE + // U+00E4: "ä" LATIN SMALL LETTER A WITH DIAERESIS + // U+00E5: "å" LATIN SMALL LETTER A WITH RING ABOVE + // U+00E6: "æ" LATIN SMALL LETTER AE + // U+0101: "ā" LATIN SMALL LETTER A WITH MACRON + // U+0103: "ă" LATIN SMALL LETTER A WITH BREVE + // U+0105: "ą" LATIN SMALL LETTER A WITH OGONEK + // U+00AA: "ª" FEMININE ORDINAL INDICATOR + /* morekeys_a */ "\u00E0,\u00E1,\u00E2,\u00E3,\u00E4,\u00E5,\u00E6,\u0101,\u0103,\u0105,\u00AA", + // U+00F2: "ò" LATIN SMALL LETTER O WITH GRAVE + // U+00F3: "ó" LATIN SMALL LETTER O WITH ACUTE + // U+00F4: "ô" LATIN SMALL LETTER O WITH CIRCUMFLEX + // U+00F5: "õ" LATIN SMALL LETTER O WITH TILDE + // U+00F6: "ö" LATIN SMALL LETTER O WITH DIAERESIS + // U+00F8: "ø" LATIN SMALL LETTER O WITH STROKE + // U+014D: "ō" LATIN SMALL LETTER O WITH MACRON + // U+014F: "ŏ" LATIN SMALL LETTER O WITH BREVE + // U+0151: "ő" LATIN SMALL LETTER O WITH DOUBLE ACUTE + // U+0153: "œ" LATIN SMALL LIGATURE OE + // U+00BA: "º" MASCULINE ORDINAL INDICATOR + /* morekeys_o */ "\u00F2,\u00F3,\u00F4,\u00F5,\u00F6,\u00F8,\u014D,\u014F,\u0151,\u0153,\u00BA", + // U+00E8: "è" LATIN SMALL LETTER E WITH GRAVE + // U+00E9: "é" LATIN SMALL LETTER E WITH ACUTE + // U+00EA: "ê" LATIN SMALL LETTER E WITH CIRCUMFLEX + // U+00EB: "ë" LATIN SMALL LETTER E WITH DIAERESIS + // U+0113: "ē" LATIN SMALL LETTER E WITH MACRON + // U+0115: "ĕ" LATIN SMALL LETTER E WITH BREVE + // U+0117: "ė" LATIN SMALL LETTER E WITH DOT ABOVE + // U+0119: "ę" LATIN SMALL LETTER E WITH OGONEK + // U+011B: "ě" LATIN SMALL LETTER E WITH CARON + /* morekeys_e */ "\u00E8,\u00E9,\u00EA,\u00EB,\u0113,\u0115,\u0117,\u0119,\u011B", + // U+00F9: "ù" LATIN SMALL LETTER U WITH GRAVE + // U+00FA: "ú" LATIN SMALL LETTER U WITH ACUTE + // U+00FB: "û" LATIN SMALL LETTER U WITH CIRCUMFLEX + // U+00FC: "ü" LATIN SMALL LETTER U WITH DIAERESIS + // U+0169: "ũ" LATIN SMALL LETTER U WITH TILDE + // U+016B: "ū" LATIN SMALL LETTER U WITH MACRON + // U+016D: "ŭ" LATIN SMALL LETTER U WITH BREVE + // U+016F: "ů" LATIN SMALL LETTER U WITH RING ABOVE + // U+0171: "ű" LATIN SMALL LETTER U WITH DOUBLE ACUTE + // U+0173: "ų" LATIN SMALL LETTER U WITH OGONEK + /* morekeys_u */ "\u00F9,\u00FA,\u00FB,\u00FC,\u0169,\u016B,\u016D,\u016F,\u0171,\u0173", + /* keylabel_to_alpha */ null, + // U+00EC: "ì" LATIN SMALL LETTER I WITH GRAVE + // U+00ED: "í" LATIN SMALL LETTER I WITH ACUTE + // U+00EE: "î" LATIN SMALL LETTER I WITH CIRCUMFLEX + // U+00EF: "ï" LATIN SMALL LETTER I WITH DIAERESIS + // U+0129: "ĩ" LATIN SMALL LETTER I WITH TILDE + // U+012B: "ī" LATIN SMALL LETTER I WITH MACRON + // U+012D: "ĭ" LATIN SMALL LETTER I WITH BREVE + // U+012F: "į" LATIN SMALL LETTER I WITH OGONEK + // U+0131: "ı" LATIN SMALL LETTER DOTLESS I + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_i */ "\u00EC,\u00ED,\u00EE,\u00EF,\u0129,\u012B,\u012D,\u012F,\u0131,\u0133", + // U+00F1: "ñ" LATIN SMALL LETTER N WITH TILDE + // U+0144: "ń" LATIN SMALL LETTER N WITH ACUTE + // U+0146: "ņ" LATIN SMALL LETTER N WITH CEDILLA + // U+0148: "ň" LATIN SMALL LETTER N WITH CARON + // U+0149: "ʼn" LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + // U+014B: "ŋ" LATIN SMALL LETTER ENG + /* morekeys_n */ "\u00F1,\u0144,\u0146,\u0148,\u0149,\u014B", + // U+00E7: "ç" LATIN SMALL LETTER C WITH CEDILLA + // U+0107: "ć" LATIN SMALL LETTER C WITH ACUTE + // U+0109: "ĉ" LATIN SMALL LETTER C WITH CIRCUMFLEX + // U+010B: "ċ" LATIN SMALL LETTER C WITH DOT ABOVE + // U+010D: "č" LATIN SMALL LETTER C WITH CARON + /* morekeys_c */ "\u00E7,\u0107,\u0109,\u010B,\u010D", + /* double_quotes */ null, + // U+00DF: "ß" LATIN SMALL LETTER SHARP S + // U+015B: "ś" LATIN SMALL LETTER S WITH ACUTE + // U+015D: "ŝ" LATIN SMALL LETTER S WITH CIRCUMFLEX + // U+015F: "ş" LATIN SMALL LETTER S WITH CEDILLA + // U+0161: "š" LATIN SMALL LETTER S WITH CARON + // U+017F: "ſ" LATIN SMALL LETTER LONG S + /* morekeys_s */ "\u00DF,\u015B,\u015D,\u015F,\u0161,\u017F", + /* single_quotes */ null, + /* keyspec_currency */ null, + // U+00FD: "ý" LATIN SMALL LETTER Y WITH ACUTE + // U+0177: "ŷ" LATIN SMALL LETTER Y WITH CIRCUMFLEX + // U+00FF: "ÿ" LATIN SMALL LETTER Y WITH DIAERESIS + // U+0133: "ij" LATIN SMALL LIGATURE IJ + /* morekeys_y */ "\u00FD,\u0177,\u00FF,\u0133", + // U+017A: "ź" LATIN SMALL LETTER Z WITH ACUTE + // U+017C: "ż" LATIN SMALL LETTER Z WITH DOT ABOVE + // U+017E: "ž" LATIN SMALL LETTER Z WITH CARON + /* morekeys_z */ "\u017A,\u017C,\u017E", + // U+010F: "ď" LATIN SMALL LETTER D WITH CARON + // U+0111: "đ" LATIN SMALL LETTER D WITH STROKE + // U+00F0: "ð" LATIN SMALL LETTER ETH + /* morekeys_d */ "\u010F,\u0111,\u00F0", + // U+00FE: "þ" LATIN SMALL LETTER THORN + // U+0163: "ţ" LATIN SMALL LETTER T WITH CEDILLA + // U+0165: "ť" LATIN SMALL LETTER T WITH CARON + // U+0167: "ŧ" LATIN SMALL LETTER T WITH STROKE + /* morekeys_t */ "\u00FE,\u0163,\u0165,\u0167", + // U+013A: "ĺ" LATIN SMALL LETTER L WITH ACUTE + // U+013C: "ļ" LATIN SMALL LETTER L WITH CEDILLA + // U+013E: "ľ" LATIN SMALL LETTER L WITH CARON + // U+0140: "ŀ" LATIN SMALL LETTER L WITH MIDDLE DOT + // U+0142: "ł" LATIN SMALL LETTER L WITH STROKE + /* morekeys_l */ "\u013A,\u013C,\u013E,\u0140,\u0142", + // U+011D: "ĝ" LATIN SMALL LETTER G WITH CIRCUMFLEX + // U+011F: "ğ" LATIN SMALL LETTER G WITH BREVE + // U+0121: "ġ" LATIN SMALL LETTER G WITH DOT ABOVE + // U+0123: "ģ" LATIN SMALL LETTER G WITH CEDILLA + /* morekeys_g */ "\u011D,\u011F,\u0121,\u0123", + /* single_angle_quotes */ null, + /* double_angle_quotes */ null, + // U+0155: "ŕ" LATIN SMALL LETTER R WITH ACUTE + // U+0157: "ŗ" LATIN SMALL LETTER R WITH CEDILLA + // U+0159: "ř" LATIN SMALL LETTER R WITH CARON + /* morekeys_r */ "\u0155,\u0157,\u0159", + // U+0137: "ķ" LATIN SMALL LETTER K WITH CEDILLA + // U+0138: "ĸ" LATIN SMALL LETTER KRA + /* morekeys_k */ "\u0137,\u0138", + /* morekeys_cyrillic_ie ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, + /* ~ morekeys_question */ + // U+0125: "ĥ" LATIN SMALL LETTER H WITH CIRCUMFLEX + /* morekeys_h */ "\u0125", + // U+0175: "ŵ" LATIN SMALL LETTER W WITH CIRCUMFLEX + /* morekeys_w */ "\u0175", + /* morekeys_east_slavic_row2_2 ~ */ + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, + /* ~ morekeys_v */ + // U+0135: "ĵ" LATIN SMALL LETTER J WITH CIRCUMFLEX + /* morekeys_j */ "\u0135", + }; + + private static final Object[] LOCALES_AND_TEXTS = { + // "locale", TEXT_ARRAY, /* numberOfNonNullText/lengthOf_TEXT_ARRAY localeName */ + "DEFAULT", TEXTS_DEFAULT, /* 176/176 DEFAULT */ + "af" , TEXTS_af, /* 7/ 13 Afrikaans */ + "ar" , TEXTS_ar, /* 55/110 Arabic */ + "az_AZ" , TEXTS_az_AZ, /* 11/ 18 Azerbaijani (Azerbaijan) */ + "be_BY" , TEXTS_be_BY, /* 9/ 32 Belarusian (Belarus) */ + "bg" , TEXTS_bg, /* 2/ 9 Bulgarian */ + "bn_BD" , TEXTS_bn_BD, /* 2/ 12 Bengali (Bangladesh) */ + "bn_IN" , TEXTS_bn_IN, /* 2/ 12 Bengali (India) */ + "ca" , TEXTS_ca, /* 11/ 99 Catalan */ + "cs" , TEXTS_cs, /* 17/ 21 Czech */ + "da" , TEXTS_da, /* 19/ 55 Danish */ + "de" , TEXTS_de, /* 16/ 66 German */ + "el" , TEXTS_el, /* 1/ 5 Greek */ + "en" , TEXTS_en, /* 8/ 10 English */ + "eo" , TEXTS_eo, /* 26/126 Esperanto */ + "es" , TEXTS_es, /* 8/ 56 Spanish */ + "et_EE" , TEXTS_et_EE, /* 22/ 27 Estonian (Estonia) */ + "eu_ES" , TEXTS_eu_ES, /* 7/ 8 Basque (Spain) */ + "fa" , TEXTS_fa, /* 58/133 Persian */ + "fi" , TEXTS_fi, /* 10/ 55 Finnish */ + "fr" , TEXTS_fr, /* 13/ 66 French */ + "gl_ES" , TEXTS_gl_ES, /* 7/ 8 Gallegan (Spain) */ + "hi" , TEXTS_hi, /* 27/ 60 Hindi */ + "hi_ZZ" , TEXTS_hi_ZZ, /* 9/118 Hindi (ZZ) */ + "hr" , TEXTS_hr, /* 9/ 20 Croatian */ + "hu" , TEXTS_hu, /* 9/ 20 Hungarian */ + "hy_AM" , TEXTS_hy_AM, /* 9/134 Armenian (Armenia) */ + "is" , TEXTS_is, /* 10/ 16 Icelandic */ + "it" , TEXTS_it, /* 11/ 66 Italian */ + "iw" , TEXTS_iw, /* 20/131 Hebrew */ + "ka_GE" , TEXTS_ka_GE, /* 3/ 11 Georgian (Georgia) */ + "kk" , TEXTS_kk, /* 15/129 Kazakh */ + "km_KH" , TEXTS_km_KH, /* 2/130 Khmer (Cambodia) */ + "kn_IN" , TEXTS_kn_IN, /* 2/ 12 Kannada (India) */ + "ky" , TEXTS_ky, /* 10/ 92 Kirghiz */ + "lo_LA" , TEXTS_lo_LA, /* 2/ 12 Lao (Laos) */ + "lt" , TEXTS_lt, /* 18/ 22 Lithuanian */ + "lv" , TEXTS_lv, /* 18/ 22 Latvian */ + "mk" , TEXTS_mk, /* 9/ 97 Macedonian */ + "ml_IN" , TEXTS_ml_IN, /* 2/ 12 Malayalam (India) */ + "mn_MN" , TEXTS_mn_MN, /* 2/ 12 Mongolian (Mongolia) */ + "mr_IN" , TEXTS_mr_IN, /* 23/ 53 Marathi (India) */ + "nb" , TEXTS_nb, /* 11/ 55 Norwegian Bokmål */ + "ne_NP" , TEXTS_ne_NP, /* 27/ 60 Nepali (Nepal) */ + "nl" , TEXTS_nl, /* 9/ 13 Dutch */ + "pl" , TEXTS_pl, /* 10/ 17 Polish */ + "pt" , TEXTS_pt, /* 6/ 8 Portuguese */ + "rm" , TEXTS_rm, /* 1/ 2 Raeto-Romance */ + "ro" , TEXTS_ro, /* 6/ 16 Romanian */ + "ru" , TEXTS_ru, /* 9/ 32 Russian */ + "si_LK" , TEXTS_si_LK, /* 2/ 12 Sinhalese (Sri Lanka) */ + "sk" , TEXTS_sk, /* 20/ 22 Slovak */ + "sl" , TEXTS_sl, /* 8/ 20 Slovenian */ + "sr" , TEXTS_sr, /* 11/ 97 Serbian */ + "sr_ZZ" , TEXTS_sr_ZZ, /* 14/118 Serbian (ZZ) */ + "sv" , TEXTS_sv, /* 21/ 55 Swedish */ + "sw" , TEXTS_sw, /* 9/ 18 Swahili */ + "ta_IN" , TEXTS_ta_IN, /* 2/ 12 Tamil (India) */ + "ta_LK" , TEXTS_ta_LK, /* 2/ 12 Tamil (Sri Lanka) */ + "ta_SG" , TEXTS_ta_SG, /* 1/ 5 Tamil (Singapore) */ + "te_IN" , TEXTS_te_IN, /* 2/ 12 Telugu (India) */ + "th" , TEXTS_th, /* 2/ 12 Thai */ + "tl" , TEXTS_tl, /* 7/ 8 Tagalog */ + "tr" , TEXTS_tr, /* 11/ 18 Turkish */ + "uk" , TEXTS_uk, /* 11/ 91 Ukrainian */ + "uz_UZ" , TEXTS_uz_UZ, /* 11/ 18 Uzbek (Uzbekistan) */ + "vi" , TEXTS_vi, /* 8/ 15 Vietnamese */ + "zu" , TEXTS_zu, /* 8/ 10 Zulu */ + "zz" , TEXTS_zz, /* 19/120 Alphabet */ + }; + + static { + for (int index = 0; index < NAMES.length; index++) { + sNameToIndexesMap.put(NAMES[index], index); + } + + for (int i = 0; i < LOCALES_AND_TEXTS.length; i += 2) { + final String locale = (String)LOCALES_AND_TEXTS[i]; + final String[] textsTable = (String[])LOCALES_AND_TEXTS[i + 1]; + sLocaleToTextsTableMap.put(locale, textsTable); + sTextsTableToLocaleMap.put(textsTable, locale); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/MatrixUtils.java b/java/src/org/kelar/inputmethod/keyboard/internal/MatrixUtils.java new file mode 100644 index 000000000..89b1e2576 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/MatrixUtils.java @@ -0,0 +1,166 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import android.util.Log; + +import java.util.Arrays; + +/** + * Utilities for matrix operations. Don't instantiate objects inside this class to prevent + * unexpected performance regressions. + */ +@UsedForTesting +public class MatrixUtils { + static final String TAG = MatrixUtils.class.getSimpleName(); + + public static class MatrixOperationFailedException extends Exception { + private static final long serialVersionUID = 4384485606788583829L; + + public MatrixOperationFailedException(String msg) { + super(msg); + Log.d(TAG, msg); + } + } + + /** + * A utility function to inverse matrix. + * Find a pivot and swap the row of squareMatrix0 and squareMatrix1 + */ + private static void findPivotAndSwapRow(final int row, final float[][] squareMatrix0, + final float[][] squareMatrix1, final int size) { + int ip = row; + float pivot = Math.abs(squareMatrix0[row][row]); + for (int i = row + 1; i < size; ++i) { + if (pivot < Math.abs(squareMatrix0[i][row])) { + ip = i; + pivot = Math.abs(squareMatrix0[i][row]); + } + } + if (ip != row) { + for (int j = 0; j < size; ++j) { + final float temp0 = squareMatrix0[ip][j]; + squareMatrix0[ip][j] = squareMatrix0[row][j]; + squareMatrix0[row][j] = temp0; + final float temp1 = squareMatrix1[ip][j]; + squareMatrix1[ip][j] = squareMatrix1[row][j]; + squareMatrix1[row][j] = temp1; + } + } + } + + /** + * A utility function to inverse matrix. This function calculates answer for each row by + * sweeping method of Gauss Jordan elimination + */ + private static void sweep(final int row, final float[][] squareMatrix0, + final float[][] squareMatrix1, final int size) throws MatrixOperationFailedException { + final float pivot = squareMatrix0[row][row]; + if (pivot == 0) { + throw new MatrixOperationFailedException("Inverse failed. Invalid pivot"); + } + for (int j = 0; j < size; ++j) { + squareMatrix0[row][j] /= pivot; + squareMatrix1[row][j] /= pivot; + } + for (int i = 0; i < size; i++) { + final float sweepTargetValue = squareMatrix0[i][row]; + if (i != row) { + for (int j = row; j < size; ++j) { + squareMatrix0[i][j] -= sweepTargetValue * squareMatrix0[row][j]; + } + for (int j = 0; j < size; ++j) { + squareMatrix1[i][j] -= sweepTargetValue * squareMatrix1[row][j]; + } + } + } + } + + /** + * A function to inverse matrix. + * The inverse matrix of squareMatrix will be output to inverseMatrix. Please notice that + * the value of squareMatrix is modified in this function and can't be resuable. + */ + @UsedForTesting + public static void inverse(final float[][] squareMatrix, + final float[][] inverseMatrix) throws MatrixOperationFailedException { + final int size = squareMatrix.length; + if (squareMatrix[0].length != size || inverseMatrix.length != size + || inverseMatrix[0].length != size) { + throw new MatrixOperationFailedException( + "--- invalid length. column should be 2 times larger than row."); + } + for (int i = 0; i < size; ++i) { + Arrays.fill(inverseMatrix[i], 0.0f); + inverseMatrix[i][i] = 1.0f; + } + for (int i = 0; i < size; ++i) { + findPivotAndSwapRow(i, squareMatrix, inverseMatrix, size); + sweep(i, squareMatrix, inverseMatrix, size); + } + } + + /** + * A matrix operation to multiply m0 and m1. + */ + @UsedForTesting + public static void multiply(final float[][] m0, final float[][] m1, + final float[][] retval) throws MatrixOperationFailedException { + if (m0[0].length != m1.length) { + throw new MatrixOperationFailedException( + "--- invalid length for multiply " + m0[0].length + ", " + m1.length); + } + final int m0h = m0.length; + final int m0w = m0[0].length; + final int m1w = m1[0].length; + if (retval.length != m0h || retval[0].length != m1w) { + throw new MatrixOperationFailedException( + "--- invalid length of retval " + retval.length + ", " + retval[0].length); + } + + for (int i = 0; i < m0h; i++) { + Arrays.fill(retval[i], 0); + for (int j = 0; j < m1w; j++) { + for (int k = 0; k < m0w; k++) { + retval[i][j] += m0[i][k] * m1[k][j]; + } + } + } + } + + /** + * A utility function to dump the specified matrix in a readable way + */ + @UsedForTesting + public static void dump(final String title, final float[][] a) { + final int column = a[0].length; + final int row = a.length; + Log.d(TAG, "Dump matrix: " + title); + Log.d(TAG, "/*---------------------"); + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < row; ++i) { + sb.setLength(0); + for (int j = 0; j < column; ++j) { + sb.append(String.format("%4f", a[i][j])).append(' '); + } + Log.d(TAG, sb.toString()); + } + Log.d(TAG, "---------------------*/"); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/ModifierKeyState.java b/java/src/org/kelar/inputmethod/keyboard/internal/ModifierKeyState.java new file mode 100644 index 000000000..18b3e6154 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/ModifierKeyState.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; + +/* package */ class ModifierKeyState { + protected static final String TAG = ModifierKeyState.class.getSimpleName(); + protected static final boolean DEBUG = false; + + protected static final int RELEASING = 0; + protected static final int PRESSING = 1; + protected static final int CHORDING = 2; + + protected final String mName; + protected int mState = RELEASING; + + public ModifierKeyState(String name) { + mName = name; + } + + public void onPress() { + final int oldState = mState; + mState = PRESSING; + if (DEBUG) + Log.d(TAG, mName + ".onPress: " + toString(oldState) + " > " + this); + } + + public void onRelease() { + final int oldState = mState; + mState = RELEASING; + if (DEBUG) + Log.d(TAG, mName + ".onRelease: " + toString(oldState) + " > " + this); + } + + public void onOtherKeyPressed() { + final int oldState = mState; + if (oldState == PRESSING) + mState = CHORDING; + if (DEBUG) + Log.d(TAG, mName + ".onOtherKeyPressed: " + toString(oldState) + " > " + this); + } + + public boolean isPressing() { + return mState == PRESSING; + } + + public boolean isReleasing() { + return mState == RELEASING; + } + + public boolean isChording() { + return mState == CHORDING; + } + + @Override + public String toString() { + return toString(mState); + } + + protected String toString(int state) { + switch (state) { + case RELEASING: return "RELEASING"; + case PRESSING: return "PRESSING"; + case CHORDING: return "CHORDING"; + default: return "UNKNOWN"; + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/MoreKeySpec.java b/java/src/org/kelar/inputmethod/keyboard/internal/MoreKeySpec.java new file mode 100644 index 000000000..533cf59c8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/MoreKeySpec.java @@ -0,0 +1,355 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.text.TextUtils; +import android.util.SparseIntArray; + +import org.kelar.inputmethod.compat.CharacterCompat; +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.latin.common.CollectionUtils; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * The more key specification object. The more keys are an array of {@link MoreKeySpec}. + * + * The more keys specification is comma separated "key specification" each of which represents one + * "more key". + * The key specification might have label or string resource reference in it. These references are + * expanded before parsing comma. + * Special character, comma ',' backslash '\' can be escaped by '\' character. + * Note that the '\' is also parsed by XML parser and {@link MoreKeySpec#splitKeySpecs(String)} + * as well. + */ +// TODO: Should extend the key specification object. +public final class MoreKeySpec { + public final int mCode; + @Nullable + public final String mLabel; + @Nullable + public final String mOutputText; + public final int mIconId; + + public MoreKeySpec(@Nonnull final String moreKeySpec, boolean needsToUpperCase, + @Nonnull final Locale locale) { + if (moreKeySpec.isEmpty()) { + throw new KeySpecParser.KeySpecParserError("Empty more key spec"); + } + final String label = KeySpecParser.getLabel(moreKeySpec); + mLabel = needsToUpperCase ? StringUtils.toTitleCaseOfKeyLabel(label, locale) : label; + final int codeInSpec = KeySpecParser.getCode(moreKeySpec); + final int code = needsToUpperCase ? StringUtils.toTitleCaseOfKeyCode(codeInSpec, locale) + : codeInSpec; + if (code == Constants.CODE_UNSPECIFIED) { + // Some letter, for example German Eszett (U+00DF: "ß"), has multiple characters + // upper case representation ("SS"). + mCode = Constants.CODE_OUTPUT_TEXT; + mOutputText = mLabel; + } else { + mCode = code; + final String outputText = KeySpecParser.getOutputText(moreKeySpec); + mOutputText = needsToUpperCase + ? StringUtils.toTitleCaseOfKeyLabel(outputText, locale) : outputText; + } + mIconId = KeySpecParser.getIconId(moreKeySpec); + } + + @Nonnull + public Key buildKey(final int x, final int y, final int labelFlags, + @Nonnull final KeyboardParams params) { + return new Key(mLabel, mIconId, mCode, mOutputText, null /* hintLabel */, labelFlags, + Key.BACKGROUND_TYPE_NORMAL, x, y, params.mDefaultKeyWidth, params.mDefaultRowHeight, + params.mHorizontalGap, params.mVerticalGap); + } + + @Override + public int hashCode() { + int hashCode = 1; + hashCode = 31 + mCode; + hashCode = hashCode * 31 + mIconId; + final String label = mLabel; + hashCode = hashCode * 31 + (label == null ? 0 : label.hashCode()); + final String outputText = mOutputText; + hashCode = hashCode * 31 + (outputText == null ? 0 : outputText.hashCode()); + return hashCode; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o instanceof MoreKeySpec) { + final MoreKeySpec other = (MoreKeySpec)o; + return mCode == other.mCode + && mIconId == other.mIconId + && TextUtils.equals(mLabel, other.mLabel) + && TextUtils.equals(mOutputText, other.mOutputText); + } + return false; + } + + @Override + public String toString() { + final String label = (mIconId == KeyboardIconsSet.ICON_UNDEFINED ? mLabel + : KeyboardIconsSet.PREFIX_ICON + KeyboardIconsSet.getIconName(mIconId)); + final String output = (mCode == Constants.CODE_OUTPUT_TEXT ? mOutputText + : Constants.printableCode(mCode)); + if (StringUtils.codePointCount(label) == 1 && label.codePointAt(0) == mCode) { + return output; + } + return label + "|" + output; + } + + public static class LettersOnBaseLayout { + private final SparseIntArray mCodes = new SparseIntArray(); + private final HashSet<String> mTexts = new HashSet<>(); + + public void addLetter(@Nonnull final Key key) { + final int code = key.getCode(); + if (CharacterCompat.isAlphabetic(code)) { + mCodes.put(code, 0); + } else if (code == Constants.CODE_OUTPUT_TEXT) { + mTexts.add(key.getOutputText()); + } + } + + public boolean contains(@Nonnull final MoreKeySpec moreKey) { + final int code = moreKey.mCode; + if (CharacterCompat.isAlphabetic(code) && mCodes.indexOfKey(code) >= 0) { + return true; + } else if (code == Constants.CODE_OUTPUT_TEXT && mTexts.contains(moreKey.mOutputText)) { + return true; + } + return false; + } + } + + @Nullable + public static MoreKeySpec[] removeRedundantMoreKeys(@Nullable final MoreKeySpec[] moreKeys, + @Nonnull final LettersOnBaseLayout lettersOnBaseLayout) { + if (moreKeys == null) { + return null; + } + final ArrayList<MoreKeySpec> filteredMoreKeys = new ArrayList<>(); + for (final MoreKeySpec moreKey : moreKeys) { + if (!lettersOnBaseLayout.contains(moreKey)) { + filteredMoreKeys.add(moreKey); + } + } + final int size = filteredMoreKeys.size(); + if (size == moreKeys.length) { + return moreKeys; + } + if (size == 0) { + return null; + } + return filteredMoreKeys.toArray(new MoreKeySpec[size]); + } + + // Constants for parsing. + private static final char COMMA = Constants.CODE_COMMA; + private static final char BACKSLASH = Constants.CODE_BACKSLASH; + private static final String ADDITIONAL_MORE_KEY_MARKER = + StringUtils.newSingleCodePointString(Constants.CODE_PERCENT); + + /** + * Split the text containing multiple key specifications separated by commas into an array of + * key specifications. + * A key specification can contain a character escaped by the backslash character, including a + * comma character. + * Note that an empty key specification will be eliminated from the result array. + * + * @param text the text containing multiple key specifications. + * @return an array of key specification text. Null if the specified <code>text</code> is empty + * or has no key specifications. + */ + @Nullable + public static String[] splitKeySpecs(@Nullable final String text) { + if (TextUtils.isEmpty(text)) { + return null; + } + final int size = text.length(); + // Optimization for one-letter key specification. + if (size == 1) { + return text.charAt(0) == COMMA ? null : new String[] { text }; + } + + ArrayList<String> list = null; + int start = 0; + // The characters in question in this loop are COMMA and BACKSLASH. These characters never + // match any high or low surrogate character. So it is OK to iterate through with char + // index. + for (int pos = 0; pos < size; pos++) { + final char c = text.charAt(pos); + if (c == COMMA) { + // Skip empty entry. + if (pos - start > 0) { + if (list == null) { + list = new ArrayList<>(); + } + list.add(text.substring(start, pos)); + } + // Skip comma + start = pos + 1; + } else if (c == BACKSLASH) { + // Skip escape character and escaped character. + pos++; + } + } + final String remain = (size - start > 0) ? text.substring(start) : null; + if (list == null) { + return remain != null ? new String[] { remain } : null; + } + if (remain != null) { + list.add(remain); + } + return list.toArray(new String[list.size()]); + } + + @Nonnull + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + @Nonnull + private static String[] filterOutEmptyString(@Nullable final String[] array) { + if (array == null) { + return EMPTY_STRING_ARRAY; + } + ArrayList<String> out = null; + for (int i = 0; i < array.length; i++) { + final String entry = array[i]; + if (TextUtils.isEmpty(entry)) { + if (out == null) { + out = CollectionUtils.arrayAsList(array, 0, i); + } + } else if (out != null) { + out.add(entry); + } + } + if (out == null) { + return array; + } + return out.toArray(new String[out.size()]); + } + + public static String[] insertAdditionalMoreKeys(@Nullable final String[] moreKeySpecs, + @Nullable final String[] additionalMoreKeySpecs) { + final String[] moreKeys = filterOutEmptyString(moreKeySpecs); + final String[] additionalMoreKeys = filterOutEmptyString(additionalMoreKeySpecs); + final int moreKeysCount = moreKeys.length; + final int additionalCount = additionalMoreKeys.length; + ArrayList<String> out = null; + int additionalIndex = 0; + for (int moreKeyIndex = 0; moreKeyIndex < moreKeysCount; moreKeyIndex++) { + final String moreKeySpec = moreKeys[moreKeyIndex]; + if (moreKeySpec.equals(ADDITIONAL_MORE_KEY_MARKER)) { + if (additionalIndex < additionalCount) { + // Replace '%' marker with additional more key specification. + final String additionalMoreKey = additionalMoreKeys[additionalIndex]; + if (out != null) { + out.add(additionalMoreKey); + } else { + moreKeys[moreKeyIndex] = additionalMoreKey; + } + additionalIndex++; + } else { + // Filter out excessive '%' marker. + if (out == null) { + out = CollectionUtils.arrayAsList(moreKeys, 0, moreKeyIndex); + } + } + } else { + if (out != null) { + out.add(moreKeySpec); + } + } + } + if (additionalCount > 0 && additionalIndex == 0) { + // No '%' marker is found in more keys. + // Insert all additional more keys to the head of more keys. + out = CollectionUtils.arrayAsList(additionalMoreKeys, additionalIndex, additionalCount); + for (int i = 0; i < moreKeysCount; i++) { + out.add(moreKeys[i]); + } + } else if (additionalIndex < additionalCount) { + // The number of '%' markers are less than additional more keys. + // Append remained additional more keys to the tail of more keys. + out = CollectionUtils.arrayAsList(moreKeys, 0, moreKeysCount); + for (int i = additionalIndex; i < additionalCount; i++) { + out.add(additionalMoreKeys[additionalIndex]); + } + } + if (out == null && moreKeysCount > 0) { + return moreKeys; + } else if (out != null && out.size() > 0) { + return out.toArray(new String[out.size()]); + } else { + return null; + } + } + + public static int getIntValue(@Nullable final String[] moreKeys, final String key, + final int defaultValue) { + if (moreKeys == null) { + return defaultValue; + } + final int keyLen = key.length(); + boolean foundValue = false; + int value = defaultValue; + for (int i = 0; i < moreKeys.length; i++) { + final String moreKeySpec = moreKeys[i]; + if (moreKeySpec == null || !moreKeySpec.startsWith(key)) { + continue; + } + moreKeys[i] = null; + try { + if (!foundValue) { + value = Integer.parseInt(moreKeySpec.substring(keyLen)); + foundValue = true; + } + } catch (NumberFormatException e) { + throw new RuntimeException( + "integer should follow after " + key + ": " + moreKeySpec); + } + } + return value; + } + + public static boolean getBooleanValue(@Nullable final String[] moreKeys, final String key) { + if (moreKeys == null) { + return false; + } + boolean value = false; + for (int i = 0; i < moreKeys.length; i++) { + final String moreKeySpec = moreKeys[i]; + if (moreKeySpec == null || !moreKeySpec.equals(key)) { + continue; + } + moreKeys[i] = null; + value = true; + } + return value; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/NonDistinctMultitouchHelper.java b/java/src/org/kelar/inputmethod/keyboard/internal/NonDistinctMultitouchHelper.java new file mode 100644 index 000000000..b2258f737 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/NonDistinctMultitouchHelper.java @@ -0,0 +1,115 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; +import android.view.MotionEvent; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.KeyDetector; +import org.kelar.inputmethod.keyboard.PointerTracker; +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +public final class NonDistinctMultitouchHelper { + private static final String TAG = NonDistinctMultitouchHelper.class.getSimpleName(); + + private static final int MAIN_POINTER_TRACKER_ID = 0; + private int mOldPointerCount = 1; + private Key mOldKey; + private int[] mLastCoords = CoordinateUtils.newInstance(); + + public void processMotionEvent(final MotionEvent me, final KeyDetector keyDetector) { + final int pointerCount = me.getPointerCount(); + final int oldPointerCount = mOldPointerCount; + mOldPointerCount = pointerCount; + // Ignore continuous multi-touch events because we can't trust the coordinates + // in multi-touch events. + if (pointerCount > 1 && oldPointerCount > 1) { + return; + } + + // Use only main pointer tracker. + final PointerTracker mainTracker = PointerTracker.getPointerTracker( + MAIN_POINTER_TRACKER_ID); + final int action = me.getActionMasked(); + final int index = me.getActionIndex(); + final long eventTime = me.getEventTime(); + final long downTime = me.getDownTime(); + + // In single-touch. + if (oldPointerCount == 1 && pointerCount == 1) { + if (me.getPointerId(index) == mainTracker.mPointerId) { + mainTracker.processMotionEvent(me, keyDetector); + return; + } + // Inject a copied event. + injectMotionEvent(action, me.getX(index), me.getY(index), downTime, eventTime, + mainTracker, keyDetector); + return; + } + + // Single-touch to multi-touch transition. + if (oldPointerCount == 1 && pointerCount == 2) { + // Send an up event for the last pointer, be cause we can't trust the coordinates of + // this multi-touch event. + mainTracker.getLastCoordinates(mLastCoords); + final int x = CoordinateUtils.x(mLastCoords); + final int y = CoordinateUtils.y(mLastCoords); + mOldKey = mainTracker.getKeyOn(x, y); + // Inject an artifact up event for the old key. + injectMotionEvent(MotionEvent.ACTION_UP, x, y, downTime, eventTime, + mainTracker, keyDetector); + return; + } + + // Multi-touch to single-touch transition. + if (oldPointerCount == 2 && pointerCount == 1) { + // Send a down event for the latest pointer if the key is different from the previous + // key. + final int x = (int)me.getX(index); + final int y = (int)me.getY(index); + final Key newKey = mainTracker.getKeyOn(x, y); + if (mOldKey != newKey) { + // Inject an artifact down event for the new key. + // An artifact up event for the new key will usually be injected as a single-touch. + injectMotionEvent(MotionEvent.ACTION_DOWN, x, y, downTime, eventTime, + mainTracker, keyDetector); + if (action == MotionEvent.ACTION_UP) { + // Inject an artifact up event for the new key also. + injectMotionEvent(MotionEvent.ACTION_UP, x, y, downTime, eventTime, + mainTracker, keyDetector); + } + } + return; + } + + Log.w(TAG, "Unknown touch panel behavior: pointer count is " + + pointerCount + " (previously " + oldPointerCount + ")"); + } + + private static void injectMotionEvent(final int action, final float x, final float y, + final long downTime, final long eventTime, final PointerTracker tracker, + final KeyDetector keyDetector) { + final MotionEvent me = MotionEvent.obtain( + downTime, eventTime, action, x, y, 0 /* metaState */); + try { + tracker.processMotionEvent(me, keyDetector); + } finally { + me.recycle(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/PointerTrackerQueue.java b/java/src/org/kelar/inputmethod/keyboard/internal/PointerTrackerQueue.java new file mode 100644 index 000000000..2a13b3f42 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/PointerTrackerQueue.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; + +import java.util.ArrayList; + +public final class PointerTrackerQueue { + private static final String TAG = PointerTrackerQueue.class.getSimpleName(); + private static final boolean DEBUG = false; + + public interface Element { + public boolean isModifier(); + public boolean isInDraggingFinger(); + public void onPhantomUpEvent(long eventTime); + public void cancelTrackingForAction(); + } + + private static final int INITIAL_CAPACITY = 10; + // Note: {@link #mExpandableArrayOfActivePointers} and {@link #mArraySize} are synchronized by + // {@link #mExpandableArrayOfActivePointers} + private final ArrayList<Element> mExpandableArrayOfActivePointers = + new ArrayList<>(INITIAL_CAPACITY); + private int mArraySize = 0; + + public int size() { + synchronized (mExpandableArrayOfActivePointers) { + return mArraySize; + } + } + + public void add(final Element pointer) { + synchronized (mExpandableArrayOfActivePointers) { + if (DEBUG) { + Log.d(TAG, "add: " + pointer + " " + this); + } + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + if (arraySize < expandableArray.size()) { + expandableArray.set(arraySize, pointer); + } else { + expandableArray.add(pointer); + } + mArraySize = arraySize + 1; + } + } + + public void remove(final Element pointer) { + synchronized (mExpandableArrayOfActivePointers) { + if (DEBUG) { + Log.d(TAG, "remove: " + pointer + " " + this); + } + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + int newIndex = 0; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element == pointer) { + if (newIndex != index) { + Log.w(TAG, "Found duplicated element in remove: " + pointer); + } + continue; // Remove this element from the expandableArray. + } + if (newIndex != index) { + // Shift this element toward the beginning of the expandableArray. + expandableArray.set(newIndex, element); + } + newIndex++; + } + mArraySize = newIndex; + } + } + + public Element getOldestElement() { + synchronized (mExpandableArrayOfActivePointers) { + return (mArraySize == 0) ? null : mExpandableArrayOfActivePointers.get(0); + } + } + + public void releaseAllPointersOlderThan(final Element pointer, final long eventTime) { + synchronized (mExpandableArrayOfActivePointers) { + if (DEBUG) { + Log.d(TAG, "releaseAllPointerOlderThan: " + pointer + " " + this); + } + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + int newIndex, index; + for (newIndex = index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element == pointer) { + break; // Stop releasing elements. + } + if (!element.isModifier()) { + element.onPhantomUpEvent(eventTime); + continue; // Remove this element from the expandableArray. + } + if (newIndex != index) { + // Shift this element toward the beginning of the expandableArray. + expandableArray.set(newIndex, element); + } + newIndex++; + } + // Shift rest of the expandableArray. + int count = 0; + for (; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element == pointer) { + count++; + if (count > 1) { + Log.w(TAG, "Found duplicated element in releaseAllPointersOlderThan: " + + pointer); + } + } + if (newIndex != index) { + // Shift this element toward the beginning of the expandableArray. + expandableArray.set(newIndex, expandableArray.get(index)); + } + newIndex++; + } + mArraySize = newIndex; + } + } + + public void releaseAllPointers(final long eventTime) { + releaseAllPointersExcept(null, eventTime); + } + + public void releaseAllPointersExcept(final Element pointer, final long eventTime) { + synchronized (mExpandableArrayOfActivePointers) { + if (DEBUG) { + if (pointer == null) { + Log.d(TAG, "releaseAllPointers: " + this); + } else { + Log.d(TAG, "releaseAllPointerExcept: " + pointer + " " + this); + } + } + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + int newIndex = 0, count = 0; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element == pointer) { + count++; + if (count > 1) { + Log.w(TAG, "Found duplicated element in releaseAllPointersExcept: " + + pointer); + } + } else { + element.onPhantomUpEvent(eventTime); + continue; // Remove this element from the expandableArray. + } + if (newIndex != index) { + // Shift this element toward the beginning of the expandableArray. + expandableArray.set(newIndex, element); + } + newIndex++; + } + mArraySize = newIndex; + } + } + + public boolean hasModifierKeyOlderThan(final Element pointer) { + synchronized (mExpandableArrayOfActivePointers) { + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element == pointer) { + return false; // Stop searching modifier key. + } + if (element.isModifier()) { + return true; + } + } + return false; + } + } + + public boolean isAnyInDraggingFinger() { + synchronized (mExpandableArrayOfActivePointers) { + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (element.isInDraggingFinger()) { + return true; + } + } + return false; + } + } + + public void cancelAllPointerTrackers() { + synchronized (mExpandableArrayOfActivePointers) { + if (DEBUG) { + Log.d(TAG, "cancelAllPointerTracker: " + this); + } + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + element.cancelTrackingForAction(); + } + } + } + + @Override + public String toString() { + synchronized (mExpandableArrayOfActivePointers) { + final StringBuilder sb = new StringBuilder(); + final ArrayList<Element> expandableArray = mExpandableArrayOfActivePointers; + final int arraySize = mArraySize; + for (int index = 0; index < arraySize; index++) { + final Element element = expandableArray.get(index); + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(element.toString()); + } + return "[" + sb.toString() + "]"; + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/RoundedLine.java b/java/src/org/kelar/inputmethod/keyboard/internal/RoundedLine.java new file mode 100644 index 000000000..f1728d703 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/RoundedLine.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.kelar.inputmethod.keyboard.internal; + +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; + +public final class RoundedLine { + private final RectF mArc1 = new RectF(); + private final RectF mArc2 = new RectF(); + private final Path mPath = new Path(); + + private static final double RADIAN_TO_DEGREE = 180.0d / Math.PI; + private static final double RIGHT_ANGLE = Math.PI / 2.0d; + + /** + * Make a rounded line path + * + * @param p1x the x-coordinate of the start point. + * @param p1y the y-coordinate of the start point. + * @param r1 the radius at the start point + * @param p2x the x-coordinate of the end point. + * @param p2y the y-coordinate of the end point. + * @param r2 the radius at the end point + * @return an instance of {@link Path} that holds the result rounded line, or an instance of + * {@link Path} that holds an empty path if the start and end points are equal. + */ + public Path makePath(final float p1x, final float p1y, final float r1, + final float p2x, final float p2y, final float r2) { + mPath.rewind(); + final double dx = p2x - p1x; + final double dy = p2y - p1y; + // Distance of the points. + final double l = Math.hypot(dx, dy); + if (Double.compare(0.0d, l) == 0) { + return mPath; // Return an empty path + } + // Angle of the line p1-p2 + final double a = Math.atan2(dy, dx); + // Difference of trail cap radius. + final double dr = r2 - r1; + // Variation of angle at trail cap. + final double ar = Math.asin(dr / l); + // The start angle of trail cap arc at P1. + final double aa = a - (RIGHT_ANGLE + ar); + // The end angle of trail cap arc at P2. + final double ab = a + (RIGHT_ANGLE + ar); + final float cosa = (float)Math.cos(aa); + final float sina = (float)Math.sin(aa); + final float cosb = (float)Math.cos(ab); + final float sinb = (float)Math.sin(ab); + // Closing point of arc at P1. + final float p1ax = p1x + r1 * cosa; + final float p1ay = p1y + r1 * sina; + // Opening point of arc at P1. + final float p1bx = p1x + r1 * cosb; + final float p1by = p1y + r1 * sinb; + // Opening point of arc at P2. + final float p2ax = p2x + r2 * cosa; + final float p2ay = p2y + r2 * sina; + // Closing point of arc at P2. + final float p2bx = p2x + r2 * cosb; + final float p2by = p2y + r2 * sinb; + // Start angle of the trail arcs. + final float angle = (float)(aa * RADIAN_TO_DEGREE); + final float ar2degree = (float)(ar * 2.0d * RADIAN_TO_DEGREE); + // Sweep angle of the trail arc at P1. + final float a1 = -180.0f + ar2degree; + // Sweep angle of the trail arc at P2. + final float a2 = 180.0f + ar2degree; + mArc1.set(p1x, p1y, p1x, p1y); + mArc1.inset(-r1, -r1); + mArc2.set(p2x, p2y, p2x, p2y); + mArc2.inset(-r2, -r2); + + // Trail cap at P1. + mPath.moveTo(p1x, p1y); + mPath.arcTo(mArc1, angle, a1); + // Trail cap at P2. + mPath.moveTo(p2x, p2y); + mPath.arcTo(mArc2, angle, a2); + // Two trapezoids connecting P1 and P2. + mPath.moveTo(p1ax, p1ay); + mPath.lineTo(p1x, p1y); + mPath.lineTo(p1bx, p1by); + mPath.lineTo(p2bx, p2by); + mPath.lineTo(p2x, p2y); + mPath.lineTo(p2ax, p2ay); + mPath.close(); + return mPath; + } + + public void getBounds(final Rect outBounds) { + // Reuse mArc1 as working variable + mPath.computeBounds(mArc1, true /* unused */); + mArc1.roundOut(outBounds); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/ShiftKeyState.java b/java/src/org/kelar/inputmethod/keyboard/internal/ShiftKeyState.java new file mode 100644 index 000000000..74c178420 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/ShiftKeyState.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.keyboard.internal; + +import android.util.Log; + +/* package */ final class ShiftKeyState extends ModifierKeyState { + private static final int PRESSING_ON_SHIFTED = 3; // both temporary shifted & shift locked + private static final int IGNORING = 4; + + public ShiftKeyState(String name) { + super(name); + } + + @Override + public void onOtherKeyPressed() { + int oldState = mState; + if (oldState == PRESSING) { + mState = CHORDING; + } else if (oldState == PRESSING_ON_SHIFTED) { + mState = IGNORING; + } + if (DEBUG) + Log.d(TAG, mName + ".onOtherKeyPressed: " + toString(oldState) + " > " + this); + } + + public void onPressOnShifted() { + int oldState = mState; + mState = PRESSING_ON_SHIFTED; + if (DEBUG) + Log.d(TAG, mName + ".onPressOnShifted: " + toString(oldState) + " > " + this); + } + + public boolean isPressingOnShifted() { + return mState == PRESSING_ON_SHIFTED; + } + + public boolean isIgnoring() { + return mState == IGNORING; + } + + @Override + public String toString() { + return toString(mState); + } + + @Override + protected String toString(int state) { + switch (state) { + case PRESSING_ON_SHIFTED: return "PRESSING_ON_SHIFTED"; + case IGNORING: return "IGNORING"; + default: return super.toString(state); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/SlidingKeyInputDrawingPreview.java b/java/src/org/kelar/inputmethod/keyboard/internal/SlidingKeyInputDrawingPreview.java new file mode 100644 index 000000000..98775ecf5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/SlidingKeyInputDrawingPreview.java @@ -0,0 +1,106 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; + +import org.kelar.inputmethod.keyboard.PointerTracker; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.CoordinateUtils; + +/** + * Draw rubber band preview graphics during sliding key input. + * + * @attr ref android.R.styleable#MainKeyboardView_slidingKeyInputPreviewColor + * @attr ref android.R.styleable#MainKeyboardView_slidingKeyInputPreviewWidth + * @attr ref android.R.styleable#MainKeyboardView_slidingKeyInputPreviewBodyRatio + * @attr ref android.R.styleable#MainKeyboardView_slidingKeyInputPreviewShadowRatio + */ +public final class SlidingKeyInputDrawingPreview extends AbstractDrawingPreview { + private final float mPreviewBodyRadius; + + private boolean mShowsSlidingKeyInputPreview; + private final int[] mPreviewFrom = CoordinateUtils.newInstance(); + private final int[] mPreviewTo = CoordinateUtils.newInstance(); + + // TODO: Finalize the rubber band preview implementation. + private final RoundedLine mRoundedLine = new RoundedLine(); + private final Paint mPaint = new Paint(); + + public SlidingKeyInputDrawingPreview(final TypedArray mainKeyboardViewAttr) { + final int previewColor = mainKeyboardViewAttr.getColor( + R.styleable.MainKeyboardView_slidingKeyInputPreviewColor, 0); + final float previewRadius = mainKeyboardViewAttr.getDimension( + R.styleable.MainKeyboardView_slidingKeyInputPreviewWidth, 0) / 2.0f; + final int PERCENTAGE_INT = 100; + final float previewBodyRatio = (float)mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_slidingKeyInputPreviewBodyRatio, PERCENTAGE_INT) + / (float)PERCENTAGE_INT; + mPreviewBodyRadius = previewRadius * previewBodyRatio; + final int previewShadowRatioInt = mainKeyboardViewAttr.getInt( + R.styleable.MainKeyboardView_slidingKeyInputPreviewShadowRatio, 0); + if (previewShadowRatioInt > 0) { + final float previewShadowRatio = (float)previewShadowRatioInt / (float)PERCENTAGE_INT; + final float shadowRadius = previewRadius * previewShadowRatio; + mPaint.setShadowLayer(shadowRadius, 0.0f, 0.0f, previewColor); + } + mPaint.setColor(previewColor); + } + + @Override + public void onDeallocateMemory() { + // Nothing to do here. + } + + public void dismissSlidingKeyInputPreview() { + mShowsSlidingKeyInputPreview = false; + invalidateDrawingView(); + } + + /** + * Draws the preview + * @param canvas The canvas where the preview is drawn. + */ + @Override + public void drawPreview(final Canvas canvas) { + if (!isPreviewEnabled() || !mShowsSlidingKeyInputPreview) { + return; + } + + // TODO: Finalize the rubber band preview implementation. + final float radius = mPreviewBodyRadius; + final Path path = mRoundedLine.makePath( + CoordinateUtils.x(mPreviewFrom), CoordinateUtils.y(mPreviewFrom), radius, + CoordinateUtils.x(mPreviewTo), CoordinateUtils.y(mPreviewTo), radius); + canvas.drawPath(path, mPaint); + } + + /** + * Set the position of the preview. + * @param tracker The new location of the preview is based on the points in PointerTracker. + */ + @Override + public void setPreviewPosition(final PointerTracker tracker) { + tracker.getDownCoordinates(mPreviewFrom); + tracker.getLastCoordinates(mPreviewTo); + mShowsSlidingKeyInputPreview = true; + invalidateDrawingView(); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/SmoothingUtils.java b/java/src/org/kelar/inputmethod/keyboard/internal/SmoothingUtils.java new file mode 100644 index 000000000..25ce30728 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/SmoothingUtils.java @@ -0,0 +1,102 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.internal.MatrixUtils.MatrixOperationFailedException; + +import android.util.Log; + +import java.util.Arrays; + +/** + * Utilities to smooth coordinates. Currently, we calculate 3d least squares formula by using + * Lagrangian smoothing + */ +@UsedForTesting +public class SmoothingUtils { + private static final String TAG = SmoothingUtils.class.getSimpleName(); + private static final boolean DEBUG = false; + + private SmoothingUtils() { + // not allowed to instantiate publicly + } + + /** + * Find a most likely 3d least squares formula for specified coordinates. + * "retval" should be a 1x4 size matrix. + */ + @UsedForTesting + public static void get3DParameters(final float[] xs, final float[] ys, + final float[][] retval) throws MatrixOperationFailedException { + final int COEFF_COUNT = 4; // Coefficient count for 3d smoothing + if (retval.length != COEFF_COUNT || retval[0].length != 1) { + Log.d(TAG, "--- invalid length of 3d retval " + retval.length + ", " + + retval[0].length); + return; + } + final int N = xs.length; + // TODO: Never isntantiate the matrix + final float[][] m0 = new float[COEFF_COUNT][COEFF_COUNT]; + final float[][] m0Inv = new float[COEFF_COUNT][COEFF_COUNT]; + final float[][] m1 = new float[COEFF_COUNT][N]; + final float[][] m2 = new float[N][1]; + + // m0 + for (int i = 0; i < COEFF_COUNT; ++i) { + Arrays.fill(m0[i], 0); + for (int j = 0; j < COEFF_COUNT; ++j) { + final int pow = i + j; + for (int k = 0; k < N; ++k) { + m0[i][j] += (float) Math.pow(xs[k], pow); + } + } + } + // m0Inv + MatrixUtils.inverse(m0, m0Inv); + if (DEBUG) { + MatrixUtils.dump("m0-1", m0Inv); + } + + // m1 + for (int i = 0; i < COEFF_COUNT; ++i) { + for (int j = 0; j < N; ++j) { + m1[i][j] = (i == 0) ? 1.0f : m1[i - 1][j] * xs[j]; + } + } + + // m2 + for (int i = 0; i < N; ++i) { + m2[i][0] = ys[i]; + } + + final float[][] m0Invxm1 = new float[COEFF_COUNT][N]; + if (DEBUG) { + MatrixUtils.dump("a0", m0Inv); + MatrixUtils.dump("a1", m1); + } + MatrixUtils.multiply(m0Inv, m1, m0Invxm1); + if (DEBUG) { + MatrixUtils.dump("a2", m0Invxm1); + MatrixUtils.dump("a3", m2); + } + MatrixUtils.multiply(m0Invxm1, m2, retval); + if (DEBUG) { + MatrixUtils.dump("result", retval); + } + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/TimerHandler.java b/java/src/org/kelar/inputmethod/keyboard/internal/TimerHandler.java new file mode 100644 index 000000000..078ca12e4 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/TimerHandler.java @@ -0,0 +1,234 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import android.os.Message; +import android.os.SystemClock; +import android.view.ViewConfiguration; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.PointerTracker; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.LeakGuardHandlerWrapper; + +import javax.annotation.Nonnull; + +public final class TimerHandler extends LeakGuardHandlerWrapper<DrawingProxy> + implements TimerProxy { + private static final int MSG_TYPING_STATE_EXPIRED = 0; + private static final int MSG_REPEAT_KEY = 1; + private static final int MSG_LONGPRESS_KEY = 2; + private static final int MSG_LONGPRESS_SHIFT_KEY = 3; + private static final int MSG_DOUBLE_TAP_SHIFT_KEY = 4; + private static final int MSG_UPDATE_BATCH_INPUT = 5; + private static final int MSG_DISMISS_KEY_PREVIEW = 6; + private static final int MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT = 7; + + private final int mIgnoreAltCodeKeyTimeout; + private final int mGestureRecognitionUpdateTime; + + public TimerHandler(@Nonnull final DrawingProxy ownerInstance, + final int ignoreAltCodeKeyTimeout, final int gestureRecognitionUpdateTime) { + super(ownerInstance); + mIgnoreAltCodeKeyTimeout = ignoreAltCodeKeyTimeout; + mGestureRecognitionUpdateTime = gestureRecognitionUpdateTime; + } + + @Override + public void handleMessage(final Message msg) { + final DrawingProxy drawingProxy = getOwnerInstance(); + if (drawingProxy == null) { + return; + } + switch (msg.what) { + case MSG_TYPING_STATE_EXPIRED: + drawingProxy.startWhileTypingAnimation(DrawingProxy.FADE_IN); + break; + case MSG_REPEAT_KEY: + final PointerTracker tracker1 = (PointerTracker) msg.obj; + tracker1.onKeyRepeat(msg.arg1 /* code */, msg.arg2 /* repeatCount */); + break; + case MSG_LONGPRESS_KEY: + case MSG_LONGPRESS_SHIFT_KEY: + cancelLongPressTimers(); + final PointerTracker tracker2 = (PointerTracker) msg.obj; + tracker2.onLongPressed(); + break; + case MSG_UPDATE_BATCH_INPUT: + final PointerTracker tracker3 = (PointerTracker) msg.obj; + tracker3.updateBatchInputByTimer(SystemClock.uptimeMillis()); + startUpdateBatchInputTimer(tracker3); + break; + case MSG_DISMISS_KEY_PREVIEW: + drawingProxy.onKeyReleased((Key) msg.obj, false /* withAnimation */); + break; + case MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT: + drawingProxy.dismissGestureFloatingPreviewTextWithoutDelay(); + break; + } + } + + @Override + public void startKeyRepeatTimerOf(@Nonnull final PointerTracker tracker, final int repeatCount, + final int delay) { + final Key key = tracker.getKey(); + if (key == null || delay == 0) { + return; + } + sendMessageDelayed( + obtainMessage(MSG_REPEAT_KEY, key.getCode(), repeatCount, tracker), delay); + } + + private void cancelKeyRepeatTimerOf(final PointerTracker tracker) { + removeMessages(MSG_REPEAT_KEY, tracker); + } + + public void cancelKeyRepeatTimers() { + removeMessages(MSG_REPEAT_KEY); + } + + // TODO: Suppress layout changes in key repeat mode + public boolean isInKeyRepeat() { + return hasMessages(MSG_REPEAT_KEY); + } + + @Override + public void startLongPressTimerOf(@Nonnull final PointerTracker tracker, final int delay) { + final Key key = tracker.getKey(); + if (key == null) { + return; + } + // Use a separate message id for long pressing shift key, because long press shift key + // timers should be canceled when other key is pressed. + final int messageId = (key.getCode() == Constants.CODE_SHIFT) + ? MSG_LONGPRESS_SHIFT_KEY : MSG_LONGPRESS_KEY; + sendMessageDelayed(obtainMessage(messageId, tracker), delay); + } + + @Override + public void cancelLongPressTimersOf(@Nonnull final PointerTracker tracker) { + removeMessages(MSG_LONGPRESS_KEY, tracker); + removeMessages(MSG_LONGPRESS_SHIFT_KEY, tracker); + } + + @Override + public void cancelLongPressShiftKeyTimer() { + removeMessages(MSG_LONGPRESS_SHIFT_KEY); + } + + public void cancelLongPressTimers() { + removeMessages(MSG_LONGPRESS_KEY); + removeMessages(MSG_LONGPRESS_SHIFT_KEY); + } + + @Override + public void startTypingStateTimer(@Nonnull final Key typedKey) { + if (typedKey.isModifier() || typedKey.altCodeWhileTyping()) { + return; + } + + final boolean isTyping = isTypingState(); + removeMessages(MSG_TYPING_STATE_EXPIRED); + final DrawingProxy drawingProxy = getOwnerInstance(); + if (drawingProxy == null) { + return; + } + + // When user hits the space or the enter key, just cancel the while-typing timer. + final int typedCode = typedKey.getCode(); + if (typedCode == Constants.CODE_SPACE || typedCode == Constants.CODE_ENTER) { + if (isTyping) { + drawingProxy.startWhileTypingAnimation(DrawingProxy.FADE_IN); + } + return; + } + + sendMessageDelayed( + obtainMessage(MSG_TYPING_STATE_EXPIRED), mIgnoreAltCodeKeyTimeout); + if (isTyping) { + return; + } + drawingProxy.startWhileTypingAnimation(DrawingProxy.FADE_OUT); + } + + @Override + public boolean isTypingState() { + return hasMessages(MSG_TYPING_STATE_EXPIRED); + } + + @Override + public void startDoubleTapShiftKeyTimer() { + sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY), + ViewConfiguration.getDoubleTapTimeout()); + } + + @Override + public void cancelDoubleTapShiftKeyTimer() { + removeMessages(MSG_DOUBLE_TAP_SHIFT_KEY); + } + + @Override + public boolean isInDoubleTapShiftKeyTimeout() { + return hasMessages(MSG_DOUBLE_TAP_SHIFT_KEY); + } + + @Override + public void cancelKeyTimersOf(@Nonnull final PointerTracker tracker) { + cancelKeyRepeatTimerOf(tracker); + cancelLongPressTimersOf(tracker); + } + + public void cancelAllKeyTimers() { + cancelKeyRepeatTimers(); + cancelLongPressTimers(); + } + + @Override + public void startUpdateBatchInputTimer(@Nonnull final PointerTracker tracker) { + if (mGestureRecognitionUpdateTime <= 0) { + return; + } + removeMessages(MSG_UPDATE_BATCH_INPUT, tracker); + sendMessageDelayed(obtainMessage(MSG_UPDATE_BATCH_INPUT, tracker), + mGestureRecognitionUpdateTime); + } + + @Override + public void cancelUpdateBatchInputTimer(@Nonnull final PointerTracker tracker) { + removeMessages(MSG_UPDATE_BATCH_INPUT, tracker); + } + + @Override + public void cancelAllUpdateBatchInputTimers() { + removeMessages(MSG_UPDATE_BATCH_INPUT); + } + + public void postDismissKeyPreview(@Nonnull final Key key, final long delay) { + sendMessageDelayed(obtainMessage(MSG_DISMISS_KEY_PREVIEW, key), delay); + } + + public void postDismissGestureFloatingPreviewText(final long delay) { + sendMessageDelayed(obtainMessage(MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT), delay); + } + + public void cancelAllMessages() { + cancelAllKeyTimers(); + cancelAllUpdateBatchInputTimers(); + removeMessages(MSG_DISMISS_KEY_PREVIEW); + removeMessages(MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT); + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/TimerProxy.java b/java/src/org/kelar/inputmethod/keyboard/internal/TimerProxy.java new file mode 100644 index 000000000..1bf3a3d0b --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/TimerProxy.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.PointerTracker; + +import javax.annotation.Nonnull; + +public interface TimerProxy { + /** + * Start a timer to detect if a user is typing keys. + * @param typedKey the key that is typed. + */ + public void startTypingStateTimer(@Nonnull Key typedKey); + + /** + * Check if a user is key typing. + * @return true if a user is in typing. + */ + public boolean isTypingState(); + + /** + * Start a timer to simulate repeated key presses while a user keep pressing a key. + * @param tracker the {@link PointerTracker} that points the key to be repeated. + * @param repeatCount the number of times that the key is repeating. Starting from 1. + * @param delay the interval delay to the next key repeat, in millisecond. + */ + public void startKeyRepeatTimerOf(@Nonnull PointerTracker tracker, int repeatCount, int delay); + + /** + * Start a timer to detect a long pressed key. + * If a key pointed by <code>tracker</code> is a shift key, start another timer to detect + * long pressed shift key. + * @param tracker the {@link PointerTracker} that starts long pressing. + * @param delay the delay to fire the long press timer, in millisecond. + */ + public void startLongPressTimerOf(@Nonnull PointerTracker tracker, int delay); + + /** + * Cancel timers for detecting a long pressed key and a long press shift key. + * @param tracker cancel long press timers of this {@link PointerTracker}. + */ + public void cancelLongPressTimersOf(@Nonnull PointerTracker tracker); + + /** + * Cancel a timer for detecting a long pressed shift key. + */ + public void cancelLongPressShiftKeyTimer(); + + /** + * Cancel timers for detecting repeated key press, long pressed key, and long pressed shift key. + * @param tracker the {@link PointerTracker} that starts timers to be canceled. + */ + public void cancelKeyTimersOf(@Nonnull PointerTracker tracker); + + /** + * Start a timer to detect double tapped shift key. + */ + public void startDoubleTapShiftKeyTimer(); + + /** + * Cancel a timer of detecting double tapped shift key. + */ + public void cancelDoubleTapShiftKeyTimer(); + + /** + * Check if a timer of detecting double tapped shift key is running. + * @return true if detecting double tapped shift key is on going. + */ + public boolean isInDoubleTapShiftKeyTimeout(); + + /** + * Start a timer to fire updating batch input while <code>tracker</code> is on hold. + * @param tracker the {@link PointerTracker} that stops moving. + */ + public void startUpdateBatchInputTimer(@Nonnull PointerTracker tracker); + + /** + * Cancel a timer of firing updating batch input. + * @param tracker the {@link PointerTracker} that resumes moving or ends gesture input. + */ + public void cancelUpdateBatchInputTimer(@Nonnull PointerTracker tracker); + + /** + * Cancel all timers of firing updating batch input. + */ + public void cancelAllUpdateBatchInputTimers(); + + public static class Adapter implements TimerProxy { + @Override + public void startTypingStateTimer(@Nonnull Key typedKey) {} + @Override + public boolean isTypingState() { return false; } + @Override + public void startKeyRepeatTimerOf(@Nonnull PointerTracker tracker, int repeatCount, + int delay) {} + @Override + public void startLongPressTimerOf(@Nonnull PointerTracker tracker, int delay) {} + @Override + public void cancelLongPressTimersOf(@Nonnull PointerTracker tracker) {} + @Override + public void cancelLongPressShiftKeyTimer() {} + @Override + public void cancelKeyTimersOf(@Nonnull PointerTracker tracker) {} + @Override + public void startDoubleTapShiftKeyTimer() {} + @Override + public void cancelDoubleTapShiftKeyTimer() {} + @Override + public boolean isInDoubleTapShiftKeyTimeout() { return false; } + @Override + public void startUpdateBatchInputTimer(@Nonnull PointerTracker tracker) {} + @Override + public void cancelUpdateBatchInputTimer(@Nonnull PointerTracker tracker) {} + @Override + public void cancelAllUpdateBatchInputTimers() {} + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/TouchPositionCorrection.java b/java/src/org/kelar/inputmethod/keyboard/internal/TouchPositionCorrection.java new file mode 100644 index 000000000..351e201e1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/TouchPositionCorrection.java @@ -0,0 +1,97 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.define.DebugFlags; + +public final class TouchPositionCorrection { + private static final int TOUCH_POSITION_CORRECTION_RECORD_SIZE = 3; + + private boolean mEnabled; + private float[] mXs; + private float[] mYs; + private float[] mRadii; + + public void load(final String[] data) { + final int dataLength = data.length; + if (dataLength % TOUCH_POSITION_CORRECTION_RECORD_SIZE != 0) { + if (DebugFlags.DEBUG_ENABLED) { + throw new RuntimeException( + "the size of touch position correction data is invalid"); + } + return; + } + + final int length = dataLength / TOUCH_POSITION_CORRECTION_RECORD_SIZE; + mXs = new float[length]; + mYs = new float[length]; + mRadii = new float[length]; + try { + for (int i = 0; i < dataLength; ++i) { + final int type = i % TOUCH_POSITION_CORRECTION_RECORD_SIZE; + final int index = i / TOUCH_POSITION_CORRECTION_RECORD_SIZE; + final float value = Float.parseFloat(data[i]); + if (type == 0) { + mXs[index] = value; + } else if (type == 1) { + mYs[index] = value; + } else { + mRadii[index] = value; + } + } + mEnabled = dataLength > 0; + } catch (NumberFormatException e) { + if (DebugFlags.DEBUG_ENABLED) { + throw new RuntimeException( + "the number format for touch position correction data is invalid"); + } + mEnabled = false; + mXs = null; + mYs = null; + mRadii = null; + } + } + + @UsedForTesting + public void setEnabled(final boolean enabled) { + mEnabled = enabled; + } + + public boolean isValid() { + return mEnabled; + } + + public int getRows() { + return mRadii.length; + } + + @SuppressWarnings({ "static-method", "unused" }) + public float getX(final int row) { + return 0.0f; + // Touch position correction data for X coordinate is obsolete. + // return mXs[row]; + } + + public float getY(final int row) { + return mYs[row]; + } + + public float getRadius(final int row) { + return mRadii[row]; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/TypingTimeRecorder.java b/java/src/org/kelar/inputmethod/keyboard/internal/TypingTimeRecorder.java new file mode 100644 index 000000000..a66976778 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/TypingTimeRecorder.java @@ -0,0 +1,72 @@ +/* + * 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 org.kelar.inputmethod.keyboard.internal; + +public final class TypingTimeRecorder { + private final int mStaticTimeThresholdAfterFastTyping; // msec + private final int mSuppressKeyPreviewAfterBatchInputDuration; + private long mLastTypingTime; + private long mLastLetterTypingTime; + private long mLastBatchInputTime; + + public TypingTimeRecorder(final int staticTimeThresholdAfterFastTyping, + final int suppressKeyPreviewAfterBatchInputDuration) { + mStaticTimeThresholdAfterFastTyping = staticTimeThresholdAfterFastTyping; + mSuppressKeyPreviewAfterBatchInputDuration = suppressKeyPreviewAfterBatchInputDuration; + } + + public boolean isInFastTyping(final long eventTime) { + final long elapsedTimeSinceLastLetterTyping = eventTime - mLastLetterTypingTime; + return elapsedTimeSinceLastLetterTyping < mStaticTimeThresholdAfterFastTyping; + } + + private boolean wasLastInputTyping() { + return mLastTypingTime >= mLastBatchInputTime; + } + + public void onCodeInput(final int code, final long eventTime) { + // Record the letter typing time when + // 1. Letter keys are typed successively without any batch input in between. + // 2. A letter key is typed within the threshold time since the last any key typing. + // 3. A non-letter key is typed within the threshold time since the last letter key typing. + if (Character.isLetter(code)) { + if (wasLastInputTyping() + || eventTime - mLastTypingTime < mStaticTimeThresholdAfterFastTyping) { + mLastLetterTypingTime = eventTime; + } + } else { + if (eventTime - mLastLetterTypingTime < mStaticTimeThresholdAfterFastTyping) { + // This non-letter typing should be treated as a part of fast typing. + mLastLetterTypingTime = eventTime; + } + } + mLastTypingTime = eventTime; + } + + public void onEndBatchInput(final long eventTime) { + mLastBatchInputTime = eventTime; + } + + public long getLastLetterTypingTime() { + return mLastLetterTypingTime; + } + + public boolean needsToSuppressKeyPreviewPopup(final long eventTime) { + return !wasLastInputTyping() + && eventTime - mLastBatchInputTime < mSuppressKeyPreviewAfterBatchInputDuration; + } +} diff --git a/java/src/org/kelar/inputmethod/keyboard/internal/UniqueKeysCache.java b/java/src/org/kelar/inputmethod/keyboard/internal/UniqueKeysCache.java new file mode 100644 index 000000000..ea6b37793 --- /dev/null +++ b/java/src/org/kelar/inputmethod/keyboard/internal/UniqueKeysCache.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.keyboard.internal; + +import org.kelar.inputmethod.keyboard.Key; + +import java.util.HashMap; + +import javax.annotation.Nonnull; + +public abstract class UniqueKeysCache { + public abstract void setEnabled(boolean enabled); + public abstract void clear(); + public abstract @Nonnull Key getUniqueKey(@Nonnull Key key); + + @Nonnull + public static final UniqueKeysCache NO_CACHE = new UniqueKeysCache() { + @Override + public void setEnabled(boolean enabled) {} + + @Override + public void clear() {} + + @Override + public Key getUniqueKey(Key key) { return key; } + }; + + @Nonnull + public static UniqueKeysCache newInstance() { + return new UniqueKeysCacheImpl(); + } + + private static final class UniqueKeysCacheImpl extends UniqueKeysCache { + private final HashMap<Key, Key> mCache; + + private boolean mEnabled; + + UniqueKeysCacheImpl() { + mCache = new HashMap<>(); + } + + @Override + public void setEnabled(final boolean enabled) { + mEnabled = enabled; + } + + @Override + public void clear() { + mCache.clear(); + } + + @Override + public Key getUniqueKey(final Key key) { + if (!mEnabled) { + return key; + } + final Key existingKey = mCache.get(key); + if (existingKey != null) { + // Reuse the existing object that equals to "key" without adding "key" to + // the cache. + return existingKey; + } + mCache.put(key, key); + return key; + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/AssetFileAddress.java b/java/src/org/kelar/inputmethod/latin/AssetFileAddress.java new file mode 100644 index 000000000..c8508f91d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/AssetFileAddress.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.latin.common.FileUtils; + +import java.io.File; + +/** + * Immutable class to hold the address of an asset. + * As opposed to a normal file, an asset is usually represented as a contiguous byte array in + * the package file. Open it correctly thus requires the name of the package it is in, but + * also the offset in the file and the length of this data. This class encapsulates these three. + */ +public final class AssetFileAddress { + public final String mFilename; + public final long mOffset; + public final long mLength; + + public AssetFileAddress(final String filename, final long offset, final long length) { + mFilename = filename; + mOffset = offset; + 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; + return makeFromFile(new File(filename)); + } + + public static AssetFileAddress makeFromFileNameAndOffset(final String filename, + final long offset, final long length) { + if (null == filename) return null; + final File f = new File(filename); + if (!f.isFile()) return null; + return new AssetFileAddress(filename, offset, length); + } + + public boolean pointsToPhysicalFile() { + return 0 == mOffset; + } + + public void deleteUnderlyingFile() { + FileUtils.deleteRecursively(new File(mFilename)); + } + + @Override + public String toString() { + return String.format("%s (offset=%d, length=%d)", mFilename, mOffset, mLength); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/AudioAndHapticFeedbackManager.java b/java/src/org/kelar/inputmethod/latin/AudioAndHapticFeedbackManager.java new file mode 100644 index 000000000..1cf7fde0b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/AudioAndHapticFeedbackManager.java @@ -0,0 +1,134 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.media.AudioManager; +import android.os.Vibrator; +import android.view.HapticFeedbackConstants; +import android.view.View; + +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +/** + * This class gathers audio feedback and haptic feedback functions. + * + * It offers a consistent and simple interface that allows LatinIME to forget about the + * complexity of settings and the like. + */ +public final class AudioAndHapticFeedbackManager { + private AudioManager mAudioManager; + private Vibrator mVibrator; + + private SettingsValues mSettingsValues; + private boolean mSoundOn; + + private static final AudioAndHapticFeedbackManager sInstance = + new AudioAndHapticFeedbackManager(); + + public static AudioAndHapticFeedbackManager getInstance() { + return sInstance; + } + + private AudioAndHapticFeedbackManager() { + // Intentional empty constructor for singleton. + } + + public static void init(final Context context) { + sInstance.initInternal(context); + } + + private void initInternal(final Context context) { + mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + + public void performHapticAndAudioFeedback(final int code, + final View viewToPerformHapticFeedbackOn) { + performHapticFeedback(viewToPerformHapticFeedbackOn); + performAudioFeedback(code); + } + + public boolean hasVibrator() { + return mVibrator != null && mVibrator.hasVibrator(); + } + + public void vibrate(final long milliseconds) { + if (mVibrator == null) { + return; + } + mVibrator.vibrate(milliseconds); + } + + private boolean reevaluateIfSoundIsOn() { + if (mSettingsValues == null || !mSettingsValues.mSoundOn || mAudioManager == null) { + return false; + } + return mAudioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL; + } + + public void performAudioFeedback(final int code) { + // if mAudioManager is null, we can't play a sound anyway, so return + if (mAudioManager == null) { + return; + } + if (!mSoundOn) { + return; + } + final int sound; + switch (code) { + case Constants.CODE_DELETE: + sound = AudioManager.FX_KEYPRESS_DELETE; + break; + case Constants.CODE_ENTER: + sound = AudioManager.FX_KEYPRESS_RETURN; + break; + case Constants.CODE_SPACE: + sound = AudioManager.FX_KEYPRESS_SPACEBAR; + break; + default: + sound = AudioManager.FX_KEYPRESS_STANDARD; + break; + } + mAudioManager.playSoundEffect(sound, mSettingsValues.mKeypressSoundVolume); + } + + public void performHapticFeedback(final View viewToPerformHapticFeedbackOn) { + if (!mSettingsValues.mVibrateOn) { + return; + } + if (mSettingsValues.mKeypressVibrationDuration >= 0) { + vibrate(mSettingsValues.mKeypressVibrationDuration); + return; + } + // Go ahead with the system default + if (viewToPerformHapticFeedbackOn != null) { + viewToPerformHapticFeedbackOn.performHapticFeedback( + HapticFeedbackConstants.KEYBOARD_TAP); + } + } + + public void onSettingsChanged(final SettingsValues settingsValues) { + mSettingsValues = settingsValues; + mSoundOn = reevaluateIfSoundIsOn(); + } + + public void onRingerModeChanged() { + mSoundOn = reevaluateIfSoundIsOn(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/BackupAgent.java b/java/src/org/kelar/inputmethod/latin/BackupAgent.java new file mode 100644 index 000000000..267014683 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/BackupAgent.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.BackupDataInput; +import android.app.backup.SharedPreferencesBackupHelper; +import android.content.SharedPreferences; +import android.os.ParcelFileDescriptor; + +import org.kelar.inputmethod.latin.settings.LocalSettingsConstants; + +import java.io.IOException; + +/** + * Backup/restore agent for LatinIME. + * Currently it backs up the default shared preferences. + */ +public final class BackupAgent extends BackupAgentHelper { + private static final String PREF_SUFFIX = "_preferences"; + + @Override + public void onCreate() { + addHelper("shared_pref", new SharedPreferencesBackupHelper(this, + getPackageName() + PREF_SUFFIX)); + } + + @Override + public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) + throws IOException { + // Let the restore operation go through + super.onRestore(data, appVersionCode, newState); + + // Remove the preferences that we don't want restored. + final SharedPreferences.Editor prefEditor = getSharedPreferences( + getPackageName() + PREF_SUFFIX, MODE_PRIVATE).edit(); + for (final String key : LocalSettingsConstants.PREFS_TO_SKIP_RESTORING) { + prefEditor.remove(key); + } + // Flush the changes to disk. + prefEditor.commit(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/BinaryDictionary.java b/java/src/org/kelar/inputmethod/latin/BinaryDictionary.java new file mode 100644 index 000000000..661339dd0 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/BinaryDictionary.java @@ -0,0 +1,669 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.FileUtils; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.FormatSpec; +import org.kelar.inputmethod.latin.makedict.FormatSpec.DictionaryOptions; +import org.kelar.inputmethod.latin.makedict.UnsupportedFormatException; +import org.kelar.inputmethod.latin.makedict.WordProperty; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.BinaryDictionaryUtils; +import org.kelar.inputmethod.latin.utils.JniUtils; +import org.kelar.inputmethod.latin.utils.WordInputEventForPersonalization; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import javax.annotation.Nonnull; + +/** + * Implements a static, compacted, binary dictionary of standard words. + */ +// TODO: All methods which should be locked need to have a suffix "Locked". +public final class BinaryDictionary extends Dictionary { + private static final String TAG = BinaryDictionary.class.getSimpleName(); + + // The cutoff returned by native for auto-commit confidence. + // Must be equal to CONFIDENCE_TO_AUTO_COMMIT in native/jni/src/defines.h + private static final int CONFIDENCE_TO_AUTO_COMMIT = 1000000; + + public static final int DICTIONARY_MAX_WORD_LENGTH = 48; + public static final int MAX_PREV_WORD_COUNT_FOR_N_GRAM = 3; + + @UsedForTesting + public static final String UNIGRAM_COUNT_QUERY = "UNIGRAM_COUNT"; + @UsedForTesting + public static final String BIGRAM_COUNT_QUERY = "BIGRAM_COUNT"; + @UsedForTesting + public static final String MAX_UNIGRAM_COUNT_QUERY = "MAX_UNIGRAM_COUNT"; + @UsedForTesting + public static final String MAX_BIGRAM_COUNT_QUERY = "MAX_BIGRAM_COUNT"; + + public static final int NOT_A_VALID_TIMESTAMP = -1; + + // Format to get unigram flags from native side via getWordPropertyNative(). + private static final int FORMAT_WORD_PROPERTY_OUTPUT_FLAG_COUNT = 5; + private static final int FORMAT_WORD_PROPERTY_IS_NOT_A_WORD_INDEX = 0; + private static final int FORMAT_WORD_PROPERTY_IS_POSSIBLY_OFFENSIVE_INDEX = 1; + private static final int FORMAT_WORD_PROPERTY_HAS_NGRAMS_INDEX = 2; + private static final int FORMAT_WORD_PROPERTY_HAS_SHORTCUTS_INDEX = 3; // DEPRECATED + private static final int FORMAT_WORD_PROPERTY_IS_BEGINNING_OF_SENTENCE_INDEX = 4; + + // Format to get probability and historical info from native side via getWordPropertyNative(). + public static final int FORMAT_WORD_PROPERTY_OUTPUT_PROBABILITY_INFO_COUNT = 4; + public static final int FORMAT_WORD_PROPERTY_PROBABILITY_INDEX = 0; + public static final int FORMAT_WORD_PROPERTY_TIMESTAMP_INDEX = 1; + public static final int FORMAT_WORD_PROPERTY_LEVEL_INDEX = 2; + public static final int FORMAT_WORD_PROPERTY_COUNT_INDEX = 3; + + public static final String DICT_FILE_NAME_SUFFIX_FOR_MIGRATION = ".migrate"; + public static final String DIR_NAME_SUFFIX_FOR_RECORD_MIGRATION = ".migrating"; + + private long mNativeDict; + private final long mDictSize; + private final String mDictFilePath; + private final boolean mUseFullEditDistance; + private final boolean mIsUpdatable; + private boolean mHasUpdated; + + private final SparseArray<DicTraverseSession> mDicTraverseSessions = new SparseArray<>(); + + // TODO: There should be a way to remove used DicTraverseSession objects from + // {@code mDicTraverseSessions}. + private DicTraverseSession getTraverseSession(final int traverseSessionId) { + synchronized(mDicTraverseSessions) { + DicTraverseSession traverseSession = mDicTraverseSessions.get(traverseSessionId); + if (traverseSession == null) { + traverseSession = new DicTraverseSession(mLocale, mNativeDict, mDictSize); + mDicTraverseSessions.put(traverseSessionId, traverseSession); + } + return traverseSession; + } + } + + /** + * Constructs binary dictionary using existing dictionary file. + * @param filename the name of the file to read through native code. + * @param offset the offset of the dictionary data within the file. + * @param length the length of the binary data. + * @param useFullEditDistance whether to use the full edit distance in suggestions + * @param dictType the dictionary type, as a human-readable string + * @param isUpdatable whether to open the dictionary file in writable mode. + */ + public BinaryDictionary(final String filename, final long offset, final long length, + final boolean useFullEditDistance, final Locale locale, final String dictType, + final boolean isUpdatable) { + super(dictType, locale); + mDictSize = length; + mDictFilePath = filename; + mIsUpdatable = isUpdatable; + mHasUpdated = false; + mUseFullEditDistance = useFullEditDistance; + loadDictionary(filename, offset, length, isUpdatable); + } + + /** + * Constructs binary dictionary on memory. + * @param filename the name of the file used to flush. + * @param useFullEditDistance whether to use the full edit distance in suggestions + * @param dictType the dictionary type, as a human-readable string + * @param formatVersion the format version of the dictionary + * @param attributeMap the attributes of the dictionary + */ + public BinaryDictionary(final String filename, final boolean useFullEditDistance, + final Locale locale, final String dictType, final long formatVersion, + final Map<String, String> attributeMap) { + super(dictType, locale); + mDictSize = 0; + mDictFilePath = filename; + // On memory dictionary is always updatable. + mIsUpdatable = true; + mHasUpdated = false; + mUseFullEditDistance = useFullEditDistance; + final String[] keyArray = new String[attributeMap.size()]; + final String[] valueArray = new String[attributeMap.size()]; + int index = 0; + for (final String key : attributeMap.keySet()) { + keyArray[index] = key; + valueArray[index] = attributeMap.get(key); + index++; + } + mNativeDict = createOnMemoryNative(formatVersion, locale.toString(), keyArray, valueArray); + } + + + static { + JniUtils.loadNativeLibrary(); + } + + private static native long openNative(String sourceDir, long dictOffset, long dictSize, + boolean isUpdatable); + private static native long createOnMemoryNative(long formatVersion, + String locale, String[] attributeKeyStringArray, String[] attributeValueStringArray); + private static native void getHeaderInfoNative(long dict, int[] outHeaderSize, + int[] outFormatVersion, ArrayList<int[]> outAttributeKeys, + ArrayList<int[]> outAttributeValues); + private static native boolean flushNative(long dict, String filePath); + private static native boolean needsToRunGCNative(long dict, boolean mindsBlockByGC); + private static native boolean flushWithGCNative(long dict, String filePath); + private static native void closeNative(long dict); + private static native int getFormatVersionNative(long dict); + private static native int getProbabilityNative(long dict, int[] word); + private static native int getMaxProbabilityOfExactMatchesNative(long dict, int[] word); + private static native int getNgramProbabilityNative(long dict, int[][] prevWordCodePointArrays, + boolean[] isBeginningOfSentenceArray, int[] word); + private static native void getWordPropertyNative(long dict, int[] word, + boolean isBeginningOfSentence, int[] outCodePoints, boolean[] outFlags, + int[] outProbabilityInfo, ArrayList<int[][]> outNgramPrevWordsArray, + ArrayList<boolean[]> outNgramPrevWordIsBeginningOfSentenceArray, + ArrayList<int[]> outNgramTargets, ArrayList<int[]> outNgramProbabilityInfo, + ArrayList<int[]> outShortcutTargets, ArrayList<Integer> outShortcutProbabilities); + private static native int getNextWordNative(long dict, int token, int[] outCodePoints, + boolean[] outIsBeginningOfSentence); + private static native void getSuggestionsNative(long dict, long proximityInfo, + long traverseSession, int[] xCoordinates, int[] yCoordinates, int[] times, + int[] pointerIds, int[] inputCodePoints, int inputSize, int[] suggestOptions, + int[][] prevWordCodePointArrays, boolean[] isBeginningOfSentenceArray, + int prevWordCount, int[] outputSuggestionCount, int[] outputCodePoints, + int[] outputScores, int[] outputIndices, int[] outputTypes, + int[] outputAutoCommitFirstWordConfidence, + float[] inOutWeightOfLangModelVsSpatialModel); + private static native boolean addUnigramEntryNative(long dict, int[] word, int probability, + int[] shortcutTarget, int shortcutProbability, boolean isBeginningOfSentence, + boolean isNotAWord, boolean isPossiblyOffensive, int timestamp); + private static native boolean removeUnigramEntryNative(long dict, int[] word); + private static native boolean addNgramEntryNative(long dict, + int[][] prevWordCodePointArrays, boolean[] isBeginningOfSentenceArray, + int[] word, int probability, int timestamp); + private static native boolean removeNgramEntryNative(long dict, + int[][] prevWordCodePointArrays, boolean[] isBeginningOfSentenceArray, int[] word); + private static native boolean updateEntriesForWordWithNgramContextNative(long dict, + int[][] prevWordCodePointArrays, boolean[] isBeginningOfSentenceArray, + int[] word, boolean isValidWord, int count, int timestamp); + private static native int updateEntriesForInputEventsNative(long dict, + WordInputEventForPersonalization[] inputEvents, int startIndex); + private static native String getPropertyNative(long dict, String query); + private static native boolean isCorruptedNative(long dict); + private static native boolean migrateNative(long dict, String dictFilePath, + long newFormatVersion); + + // TODO: Move native dict into session + private void loadDictionary(final String path, final long startOffset, + final long length, final boolean isUpdatable) { + mHasUpdated = false; + mNativeDict = openNative(path, startOffset, length, isUpdatable); + } + + // TODO: Check isCorrupted() for main dictionaries. + public boolean isCorrupted() { + if (!isValidDictionary()) { + return false; + } + if (!isCorruptedNative(mNativeDict)) { + return false; + } + // TODO: Record the corruption. + Log.e(TAG, "BinaryDictionary (" + mDictFilePath + ") is corrupted."); + Log.e(TAG, "locale: " + mLocale); + Log.e(TAG, "dict size: " + mDictSize); + Log.e(TAG, "updatable: " + mIsUpdatable); + return true; + } + + public DictionaryHeader getHeader() throws UnsupportedFormatException { + if (mNativeDict == 0) { + return null; + } + final int[] outHeaderSize = new int[1]; + final int[] outFormatVersion = new int[1]; + final ArrayList<int[]> outAttributeKeys = new ArrayList<>(); + final ArrayList<int[]> outAttributeValues = new ArrayList<>(); + getHeaderInfoNative(mNativeDict, outHeaderSize, outFormatVersion, outAttributeKeys, + outAttributeValues); + final HashMap<String, String> attributes = new HashMap<>(); + for (int i = 0; i < outAttributeKeys.size(); i++) { + final String attributeKey = StringUtils.getStringFromNullTerminatedCodePointArray( + outAttributeKeys.get(i)); + final String attributeValue = StringUtils.getStringFromNullTerminatedCodePointArray( + outAttributeValues.get(i)); + attributes.put(attributeKey, attributeValue); + } + final boolean hasHistoricalInfo = DictionaryHeader.ATTRIBUTE_VALUE_TRUE.equals( + attributes.get(DictionaryHeader.HAS_HISTORICAL_INFO_KEY)); + return new DictionaryHeader(outHeaderSize[0], new DictionaryOptions(attributes), + new FormatSpec.FormatOptions(outFormatVersion[0], hasHistoricalInfo)); + } + + @Override + public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int sessionId, final float weightForLocale, + final float[] inOutWeightOfLangModelVsSpatialModel) { + if (!isValidDictionary()) { + return null; + } + final DicTraverseSession session = getTraverseSession(sessionId); + Arrays.fill(session.mInputCodePoints, Constants.NOT_A_CODE); + ngramContext.outputToArray(session.mPrevWordCodePointArrays, + session.mIsBeginningOfSentenceArray); + final InputPointers inputPointers = composedData.mInputPointers; + final boolean isGesture = composedData.mIsBatchMode; + final int inputSize; + if (!isGesture) { + inputSize = + composedData.copyCodePointsExceptTrailingSingleQuotesAndReturnCodePointCount( + session.mInputCodePoints); + if (inputSize < 0) { + return null; + } + } else { + inputSize = inputPointers.getPointerSize(); + } + session.mNativeSuggestOptions.setUseFullEditDistance(mUseFullEditDistance); + session.mNativeSuggestOptions.setIsGesture(isGesture); + session.mNativeSuggestOptions.setBlockOffensiveWords( + settingsValuesForSuggestion.mBlockPotentiallyOffensive); + session.mNativeSuggestOptions.setWeightForLocale(weightForLocale); + if (inOutWeightOfLangModelVsSpatialModel != null) { + session.mInputOutputWeightOfLangModelVsSpatialModel[0] = + inOutWeightOfLangModelVsSpatialModel[0]; + } else { + session.mInputOutputWeightOfLangModelVsSpatialModel[0] = + Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL; + } + // TOOD: Pass multiple previous words information for n-gram. + getSuggestionsNative(mNativeDict, proximityInfoHandle, + getTraverseSession(sessionId).getSession(), inputPointers.getXCoordinates(), + inputPointers.getYCoordinates(), inputPointers.getTimes(), + inputPointers.getPointerIds(), session.mInputCodePoints, inputSize, + session.mNativeSuggestOptions.getOptions(), session.mPrevWordCodePointArrays, + session.mIsBeginningOfSentenceArray, ngramContext.getPrevWordCount(), + session.mOutputSuggestionCount, session.mOutputCodePoints, session.mOutputScores, + session.mSpaceIndices, session.mOutputTypes, + session.mOutputAutoCommitFirstWordConfidence, + session.mInputOutputWeightOfLangModelVsSpatialModel); + if (inOutWeightOfLangModelVsSpatialModel != null) { + inOutWeightOfLangModelVsSpatialModel[0] = + session.mInputOutputWeightOfLangModelVsSpatialModel[0]; + } + final int count = session.mOutputSuggestionCount[0]; + final ArrayList<SuggestedWordInfo> suggestions = new ArrayList<>(); + for (int j = 0; j < count; ++j) { + final int start = j * DICTIONARY_MAX_WORD_LENGTH; + int len = 0; + while (len < DICTIONARY_MAX_WORD_LENGTH + && session.mOutputCodePoints[start + len] != 0) { + ++len; + } + if (len > 0) { + suggestions.add(new SuggestedWordInfo( + new String(session.mOutputCodePoints, start, len), + "" /* prevWordsContext */, + (int)(session.mOutputScores[j] * weightForLocale), + session.mOutputTypes[j], + this /* sourceDict */, + session.mSpaceIndices[j] /* indexOfTouchPointOfSecondWord */, + session.mOutputAutoCommitFirstWordConfidence[0])); + } + } + return suggestions; + } + + public boolean isValidDictionary() { + return mNativeDict != 0; + } + + public int getFormatVersion() { + return getFormatVersionNative(mNativeDict); + } + + @Override + public boolean isInDictionary(final String word) { + return getFrequency(word) != NOT_A_PROBABILITY; + } + + @Override + public int getFrequency(final String word) { + if (TextUtils.isEmpty(word)) { + return NOT_A_PROBABILITY; + } + final int[] codePoints = StringUtils.toCodePointArray(word); + return getProbabilityNative(mNativeDict, codePoints); + } + + @Override + public int getMaxFrequencyOfExactMatches(final String word) { + if (TextUtils.isEmpty(word)) { + return NOT_A_PROBABILITY; + } + final int[] codePoints = StringUtils.toCodePointArray(word); + return getMaxProbabilityOfExactMatchesNative(mNativeDict, codePoints); + } + + @UsedForTesting + public boolean isValidNgram(final NgramContext ngramContext, final String word) { + return getNgramProbability(ngramContext, word) != NOT_A_PROBABILITY; + } + + public int getNgramProbability(final NgramContext ngramContext, final String word) { + if (!ngramContext.isValid() || TextUtils.isEmpty(word)) { + return NOT_A_PROBABILITY; + } + final int[][] prevWordCodePointArrays = new int[ngramContext.getPrevWordCount()][]; + final boolean[] isBeginningOfSentenceArray = new boolean[ngramContext.getPrevWordCount()]; + ngramContext.outputToArray(prevWordCodePointArrays, isBeginningOfSentenceArray); + final int[] wordCodePoints = StringUtils.toCodePointArray(word); + return getNgramProbabilityNative(mNativeDict, prevWordCodePointArrays, + isBeginningOfSentenceArray, wordCodePoints); + } + + public WordProperty getWordProperty(final String word, final boolean isBeginningOfSentence) { + if (word == null) { + return null; + } + final int[] codePoints = StringUtils.toCodePointArray(word); + final int[] outCodePoints = new int[DICTIONARY_MAX_WORD_LENGTH]; + final boolean[] outFlags = new boolean[FORMAT_WORD_PROPERTY_OUTPUT_FLAG_COUNT]; + final int[] outProbabilityInfo = + new int[FORMAT_WORD_PROPERTY_OUTPUT_PROBABILITY_INFO_COUNT]; + final ArrayList<int[][]> outNgramPrevWordsArray = new ArrayList<>(); + final ArrayList<boolean[]> outNgramPrevWordIsBeginningOfSentenceArray = + new ArrayList<>(); + final ArrayList<int[]> outNgramTargets = new ArrayList<>(); + final ArrayList<int[]> outNgramProbabilityInfo = new ArrayList<>(); + final ArrayList<int[]> outShortcutTargets = new ArrayList<>(); + final ArrayList<Integer> outShortcutProbabilities = new ArrayList<>(); + getWordPropertyNative(mNativeDict, codePoints, isBeginningOfSentence, outCodePoints, + outFlags, outProbabilityInfo, outNgramPrevWordsArray, + outNgramPrevWordIsBeginningOfSentenceArray, outNgramTargets, + outNgramProbabilityInfo, outShortcutTargets, outShortcutProbabilities); + return new WordProperty(codePoints, + outFlags[FORMAT_WORD_PROPERTY_IS_NOT_A_WORD_INDEX], + outFlags[FORMAT_WORD_PROPERTY_IS_POSSIBLY_OFFENSIVE_INDEX], + outFlags[FORMAT_WORD_PROPERTY_HAS_NGRAMS_INDEX], + outFlags[FORMAT_WORD_PROPERTY_IS_BEGINNING_OF_SENTENCE_INDEX], outProbabilityInfo, + outNgramPrevWordsArray, outNgramPrevWordIsBeginningOfSentenceArray, + outNgramTargets, outNgramProbabilityInfo); + } + + public static class GetNextWordPropertyResult { + public WordProperty mWordProperty; + public int mNextToken; + + public GetNextWordPropertyResult(final WordProperty wordProperty, final int nextToken) { + mWordProperty = wordProperty; + mNextToken = nextToken; + } + } + + /** + * Method to iterate all words in the dictionary for makedict. + * If token is 0, this method newly starts iterating the dictionary. + */ + public GetNextWordPropertyResult getNextWordProperty(final int token) { + final int[] codePoints = new int[DICTIONARY_MAX_WORD_LENGTH]; + final boolean[] isBeginningOfSentence = new boolean[1]; + final int nextToken = getNextWordNative(mNativeDict, token, codePoints, + isBeginningOfSentence); + final String word = StringUtils.getStringFromNullTerminatedCodePointArray(codePoints); + return new GetNextWordPropertyResult( + getWordProperty(word, isBeginningOfSentence[0]), nextToken); + } + + // Add a unigram entry to binary dictionary with unigram attributes in native code. + public boolean addUnigramEntry( + final String word, final int probability, final boolean isBeginningOfSentence, + final boolean isNotAWord, final boolean isPossiblyOffensive, final int timestamp) { + if (word == null || (word.isEmpty() && !isBeginningOfSentence)) { + return false; + } + final int[] codePoints = StringUtils.toCodePointArray(word); + if (!addUnigramEntryNative(mNativeDict, codePoints, probability, + null /* shortcutTargetCodePoints */, 0 /* shortcutProbability */, + isBeginningOfSentence, isNotAWord, isPossiblyOffensive, timestamp)) { + return false; + } + mHasUpdated = true; + return true; + } + + // Remove a unigram entry from the binary dictionary in native code. + public boolean removeUnigramEntry(final String word) { + if (TextUtils.isEmpty(word)) { + return false; + } + final int[] codePoints = StringUtils.toCodePointArray(word); + if (!removeUnigramEntryNative(mNativeDict, codePoints)) { + return false; + } + mHasUpdated = true; + return true; + } + + // Add an n-gram entry to the binary dictionary with timestamp in native code. + public boolean addNgramEntry(final NgramContext ngramContext, final String word, + final int probability, final int timestamp) { + if (!ngramContext.isValid() || TextUtils.isEmpty(word)) { + return false; + } + final int[][] prevWordCodePointArrays = new int[ngramContext.getPrevWordCount()][]; + final boolean[] isBeginningOfSentenceArray = new boolean[ngramContext.getPrevWordCount()]; + ngramContext.outputToArray(prevWordCodePointArrays, isBeginningOfSentenceArray); + final int[] wordCodePoints = StringUtils.toCodePointArray(word); + if (!addNgramEntryNative(mNativeDict, prevWordCodePointArrays, + isBeginningOfSentenceArray, wordCodePoints, probability, timestamp)) { + return false; + } + mHasUpdated = true; + return true; + } + + // Update entries for the word occurrence with the ngramContext. + public boolean updateEntriesForWordWithNgramContext(@Nonnull final NgramContext ngramContext, + final String word, final boolean isValidWord, final int count, final int timestamp) { + if (TextUtils.isEmpty(word)) { + return false; + } + final int[][] prevWordCodePointArrays = new int[ngramContext.getPrevWordCount()][]; + final boolean[] isBeginningOfSentenceArray = new boolean[ngramContext.getPrevWordCount()]; + ngramContext.outputToArray(prevWordCodePointArrays, isBeginningOfSentenceArray); + final int[] wordCodePoints = StringUtils.toCodePointArray(word); + if (!updateEntriesForWordWithNgramContextNative(mNativeDict, prevWordCodePointArrays, + isBeginningOfSentenceArray, wordCodePoints, isValidWord, count, timestamp)) { + return false; + } + mHasUpdated = true; + return true; + } + + @UsedForTesting + public void updateEntriesForInputEvents(final WordInputEventForPersonalization[] inputEvents) { + if (!isValidDictionary()) { + return; + } + int processedEventCount = 0; + while (processedEventCount < inputEvents.length) { + if (needsToRunGC(true /* mindsBlockByGC */)) { + flushWithGC(); + } + processedEventCount = updateEntriesForInputEventsNative(mNativeDict, inputEvents, + processedEventCount); + mHasUpdated = true; + if (processedEventCount <= 0) { + return; + } + } + } + + private void reopen() { + close(); + final File dictFile = new File(mDictFilePath); + // WARNING: Because we pass 0 as the offset and file.length() as the length, this can + // only be called for actual files. Right now it's only called by the flush() family of + // functions, which require an updatable dictionary, so it's okay. But beware. + loadDictionary(dictFile.getAbsolutePath(), 0 /* startOffset */, + dictFile.length(), mIsUpdatable); + } + + // Flush to dict file if the dictionary has been updated. + public boolean flush() { + if (!isValidDictionary()) { + return false; + } + if (mHasUpdated) { + if (!flushNative(mNativeDict, mDictFilePath)) { + return false; + } + reopen(); + } + return true; + } + + // Run GC and flush to dict file if the dictionary has been updated. + public boolean flushWithGCIfHasUpdated() { + if (mHasUpdated) { + return flushWithGC(); + } + return true; + } + + // Run GC and flush to dict file. + public boolean flushWithGC() { + if (!isValidDictionary()) { + return false; + } + if (!flushWithGCNative(mNativeDict, mDictFilePath)) { + return false; + } + reopen(); + return true; + } + + /** + * Checks whether GC is needed to run or not. + * @param mindsBlockByGC Whether to mind operations blocked by GC. We don't need to care about + * the blocking in some situations such as in idle time or just before closing. + * @return whether GC is needed to run or not. + */ + public boolean needsToRunGC(final boolean mindsBlockByGC) { + if (!isValidDictionary()) { + return false; + } + return needsToRunGCNative(mNativeDict, mindsBlockByGC); + } + + public boolean migrateTo(final int newFormatVersion) { + if (!isValidDictionary()) { + return false; + } + final File isMigratingDir = + new File(mDictFilePath + DIR_NAME_SUFFIX_FOR_RECORD_MIGRATION); + if (isMigratingDir.exists()) { + isMigratingDir.delete(); + Log.e(TAG, "Previous migration attempt failed probably due to a crash. " + + "Giving up using the old dictionary (" + mDictFilePath + ")."); + return false; + } + if (!isMigratingDir.mkdir()) { + Log.e(TAG, "Cannot create a dir (" + isMigratingDir.getAbsolutePath() + + ") to record migration."); + return false; + } + try { + final String tmpDictFilePath = mDictFilePath + DICT_FILE_NAME_SUFFIX_FOR_MIGRATION; + if (!migrateNative(mNativeDict, tmpDictFilePath, newFormatVersion)) { + return false; + } + close(); + final File dictFile = new File(mDictFilePath); + final File tmpDictFile = new File(tmpDictFilePath); + if (!FileUtils.deleteRecursively(dictFile)) { + return false; + } + if (!BinaryDictionaryUtils.renameDict(tmpDictFile, dictFile)) { + return false; + } + loadDictionary(dictFile.getAbsolutePath(), 0 /* startOffset */, + dictFile.length(), mIsUpdatable); + return true; + } finally { + isMigratingDir.delete(); + } + } + + @UsedForTesting + public String getPropertyForGettingStats(final String query) { + if (!isValidDictionary()) { + return ""; + } + return getPropertyNative(mNativeDict, query); + } + + @Override + public boolean shouldAutoCommit(final SuggestedWordInfo candidate) { + return candidate.mAutoCommitFirstWordConfidence > CONFIDENCE_TO_AUTO_COMMIT; + } + + @Override + public void close() { + synchronized (mDicTraverseSessions) { + final int sessionsSize = mDicTraverseSessions.size(); + for (int index = 0; index < sessionsSize; ++index) { + final DicTraverseSession traverseSession = mDicTraverseSessions.valueAt(index); + if (traverseSession != null) { + traverseSession.close(); + } + } + mDicTraverseSessions.clear(); + } + closeInternalLocked(); + } + + private synchronized void closeInternalLocked() { + if (mNativeDict != 0) { + closeNative(mNativeDict); + mNativeDict = 0; + } + } + + // TODO: Manage BinaryDictionary instances without using WeakReference or something. + @Override + protected void finalize() throws Throwable { + try { + closeInternalLocked(); + } finally { + super.finalize(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/BinaryDictionaryFileDumper.java b/java/src/org/kelar/inputmethod/latin/BinaryDictionaryFileDumper.java new file mode 100644 index 000000000..ab350576a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/BinaryDictionaryFileDumper.java @@ -0,0 +1,569 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.dictionarypack.DictionaryPackConstants; +import org.kelar.inputmethod.dictionarypack.MD5Calculator; +import org.kelar.inputmethod.dictionarypack.UpdateHandler; +import org.kelar.inputmethod.latin.common.FileUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.utils.DictionaryInfoUtils; +import org.kelar.inputmethod.latin.utils.DictionaryInfoUtils.DictionaryInfo; +import org.kelar.inputmethod.latin.utils.FileTransforms; +import org.kelar.inputmethod.latin.utils.MetadataFileUriGetter; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Group class for static methods to help with creation and getting of the binary dictionary + * file from the dictionary provider + */ +public final class BinaryDictionaryFileDumper { + private static final String TAG = BinaryDictionaryFileDumper.class.getSimpleName(); + private static final boolean DEBUG = false; + + /** + * The size of the temporary buffer to copy files. + */ + private static final int FILE_READ_BUFFER_SIZE = 8192; + // TODO: make the following data common with the native code + private static final byte[] MAGIC_NUMBER_VERSION_1 = + new byte[] { (byte)0x78, (byte)0xB1, (byte)0x00, (byte)0x00 }; + private static final byte[] MAGIC_NUMBER_VERSION_2 = + new byte[] { (byte)0x9B, (byte)0xC1, (byte)0x3A, (byte)0xFE }; + + private static final boolean SHOULD_VERIFY_MAGIC_NUMBER = + DecoderSpecificConstants.SHOULD_VERIFY_MAGIC_NUMBER; + private static final boolean SHOULD_VERIFY_CHECKSUM = + DecoderSpecificConstants.SHOULD_VERIFY_CHECKSUM; + + private static final String DICTIONARY_PROJECTION[] = { "id" }; + + private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt"; + private static final String QUERY_PARAMETER_TRUE = "true"; + private static final String QUERY_PARAMETER_DELETE_RESULT = "result"; + private static final String QUERY_PARAMETER_SUCCESS = "success"; + private static final String QUERY_PARAMETER_FAILURE = "failure"; + + // Using protocol version 2 to communicate with the dictionary pack + private static final String QUERY_PARAMETER_PROTOCOL = "protocol"; + private static final String QUERY_PARAMETER_PROTOCOL_VALUE = "2"; + + // The path fragment to append after the client ID for dictionary info requests. + private static final String QUERY_PATH_DICT_INFO = "dict"; + // The path fragment to append after the client ID for dictionary datafile requests. + private static final String QUERY_PATH_DATAFILE = "datafile"; + // The path fragment to append after the client ID for updating the metadata URI. + private static final String QUERY_PATH_METADATA = "metadata"; + private static final String INSERT_METADATA_CLIENT_ID_COLUMN = "clientid"; + private static final String INSERT_METADATA_METADATA_URI_COLUMN = "uri"; + private static final String INSERT_METADATA_METADATA_ADDITIONAL_ID_COLUMN = "additionalid"; + + // Prevents this class to be accidentally instantiated. + private BinaryDictionaryFileDumper() { + } + + /** + * Returns a URI builder pointing to the dictionary pack. + * + * This creates a URI builder able to build a URI pointing to the dictionary + * pack content provider for a specific dictionary id. + */ + public static Uri.Builder getProviderUriBuilder(final String path) { + return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT) + .authority(DictionaryPackConstants.AUTHORITY).appendPath(path); + } + + /** + * Gets the content URI builder for a specified type. + * + * Supported types include QUERY_PATH_DICT_INFO, which takes the locale as + * the extraPath argument, and QUERY_PATH_DATAFILE, which needs a wordlist ID + * as the extraPath argument. + * + * @param clientId the clientId to use + * @param contentProviderClient the instance of content provider client + * @param queryPathType the path element encoding the type + * @param extraPath optional extra argument for this type (typically word list id) + * @return a builder that can build the URI for the best supported protocol version + * @throws RemoteException if the client can't be contacted + */ + private static Uri.Builder getContentUriBuilderForType(final String clientId, + final ContentProviderClient contentProviderClient, final String queryPathType, + final String extraPath) throws RemoteException { + // Check whether protocol v2 is supported by building a v2 URI and calling getType() + // on it. If this returns null, v2 is not supported. + final Uri.Builder uriV2Builder = getProviderUriBuilder(clientId); + uriV2Builder.appendPath(queryPathType); + uriV2Builder.appendPath(extraPath); + uriV2Builder.appendQueryParameter(QUERY_PARAMETER_PROTOCOL, + QUERY_PARAMETER_PROTOCOL_VALUE); + if (null != contentProviderClient.getType(uriV2Builder.build())) return uriV2Builder; + // Protocol v2 is not supported, so create and return the protocol v1 uri. + return getProviderUriBuilder(extraPath); + } + + /** + * Queries a content provider for the list of word lists for a specific locale + * available to copy into Latin IME. + */ + private static List<WordListInfo> getWordListWordListInfos(final Locale locale, + final Context context, final boolean hasDefaultWordList) { + final String clientId = context.getString(R.string.dictionary_pack_client_id); + final ContentProviderClient client = context.getContentResolver(). + acquireContentProviderClient(getProviderUriBuilder("").build()); + if (null == client) return Collections.<WordListInfo>emptyList(); + Cursor cursor = null; + try { + final Uri.Builder builder = getContentUriBuilderForType(clientId, client, + QUERY_PATH_DICT_INFO, locale.toString()); + if (!hasDefaultWordList) { + builder.appendQueryParameter(QUERY_PARAMETER_MAY_PROMPT_USER, + QUERY_PARAMETER_TRUE); + } + final Uri queryUri = builder.build(); + final boolean isProtocolV2 = (QUERY_PARAMETER_PROTOCOL_VALUE.equals( + queryUri.getQueryParameter(QUERY_PARAMETER_PROTOCOL))); + + cursor = client.query(queryUri, DICTIONARY_PROJECTION, null, null, null); + if (isProtocolV2 && null == cursor) { + reinitializeClientRecordInDictionaryContentProvider(context, client, clientId); + cursor = client.query(queryUri, DICTIONARY_PROJECTION, null, null, null); + } + if (null == cursor) return Collections.<WordListInfo>emptyList(); + if (cursor.getCount() <= 0 || !cursor.moveToFirst()) { + return Collections.<WordListInfo>emptyList(); + } + final ArrayList<WordListInfo> list = new ArrayList<>(); + do { + final String wordListId = cursor.getString(0); + final String wordListLocale = cursor.getString(1); + final String wordListRawChecksum = cursor.getString(2); + if (TextUtils.isEmpty(wordListId)) continue; + list.add(new WordListInfo(wordListId, wordListLocale, wordListRawChecksum)); + } while (cursor.moveToNext()); + return list; + } catch (RemoteException e) { + // The documentation is unclear as to in which cases this may happen, but it probably + // happens when the content provider got suddenly killed because it crashed or because + // the user disabled it through Settings. + Log.e(TAG, "RemoteException: communication with the dictionary pack cut", e); + return Collections.<WordListInfo>emptyList(); + } catch (Exception e) { + // A crash here is dangerous because crashing here would brick any encrypted device - + // we need the keyboard to be up and working to enter the password, so we don't want + // to die no matter what. So let's be as safe as possible. + Log.e(TAG, "Unexpected exception communicating with the dictionary pack", e); + return Collections.<WordListInfo>emptyList(); + } finally { + if (null != cursor) { + cursor.close(); + } + client.release(); + } + } + + + /** + * Helper method to encapsulate exception handling. + */ + private static AssetFileDescriptor openAssetFileDescriptor( + final ContentProviderClient providerClient, final Uri uri) { + try { + return providerClient.openAssetFile(uri, "r"); + } catch (FileNotFoundException e) { + // I don't want to log the word list URI here for security concerns. The exception + // contains the name of the file, so let's not pass it to Log.e here. + Log.e(TAG, "Could not find a word list from the dictionary provider." + /* intentionally don't pass the exception (see comment above) */); + return null; + } catch (RemoteException e) { + Log.e(TAG, "Can't communicate with the dictionary pack", e); + return null; + } + } + + /** + * Stages a word list the id of which is passed as an argument. This will write the file + * to the cache file name designated by its id and locale, overwriting it if already present + * and creating it (and its containing directory) if necessary. + */ + private static void installWordListToStaging(final String wordlistId, final String locale, + final String rawChecksum, final ContentProviderClient providerClient, + final Context context) { + final int COMPRESSED_CRYPTED_COMPRESSED = 0; + final int CRYPTED_COMPRESSED = 1; + final int COMPRESSED_CRYPTED = 2; + final int COMPRESSED_ONLY = 3; + final int CRYPTED_ONLY = 4; + final int NONE = 5; + final int MODE_MIN = COMPRESSED_CRYPTED_COMPRESSED; + final int MODE_MAX = NONE; + + final String clientId = context.getString(R.string.dictionary_pack_client_id); + final Uri.Builder wordListUriBuilder; + try { + wordListUriBuilder = getContentUriBuilderForType(clientId, + providerClient, QUERY_PATH_DATAFILE, wordlistId /* extraPath */); + } catch (RemoteException e) { + Log.e(TAG, "Can't communicate with the dictionary pack", e); + return; + } + final String finalFileName = + DictionaryInfoUtils.getStagingFileName(wordlistId, locale, context); + String tempFileName; + try { + tempFileName = BinaryDictionaryGetter.getTempFileName(wordlistId, context); + } catch (IOException e) { + Log.e(TAG, "Can't open the temporary file", e); + return; + } + + for (int mode = MODE_MIN; mode <= MODE_MAX; ++mode) { + final InputStream originalSourceStream; + InputStream inputStream = null; + InputStream uncompressedStream = null; + InputStream decryptedStream = null; + BufferedInputStream bufferedInputStream = null; + File outputFile = null; + BufferedOutputStream bufferedOutputStream = null; + AssetFileDescriptor afd = null; + final Uri wordListUri = wordListUriBuilder.build(); + try { + // Open input. + afd = openAssetFileDescriptor(providerClient, wordListUri); + // If we can't open it at all, don't even try a number of times. + if (null == afd) return; + originalSourceStream = afd.createInputStream(); + // Open output. + outputFile = new File(tempFileName); + // Just to be sure, delete the file. This may fail silently, and return false: this + // is the right thing to do, as we just want to continue anyway. + outputFile.delete(); + // Get the appropriate decryption method for this try + switch (mode) { + case COMPRESSED_CRYPTED_COMPRESSED: + uncompressedStream = + FileTransforms.getUncompressedStream(originalSourceStream); + decryptedStream = FileTransforms.getDecryptedStream(uncompressedStream); + inputStream = FileTransforms.getUncompressedStream(decryptedStream); + break; + case CRYPTED_COMPRESSED: + decryptedStream = FileTransforms.getDecryptedStream(originalSourceStream); + inputStream = FileTransforms.getUncompressedStream(decryptedStream); + break; + case COMPRESSED_CRYPTED: + uncompressedStream = + FileTransforms.getUncompressedStream(originalSourceStream); + inputStream = FileTransforms.getDecryptedStream(uncompressedStream); + break; + case COMPRESSED_ONLY: + inputStream = FileTransforms.getUncompressedStream(originalSourceStream); + break; + case CRYPTED_ONLY: + inputStream = FileTransforms.getDecryptedStream(originalSourceStream); + break; + case NONE: + inputStream = originalSourceStream; + break; + } + bufferedInputStream = new BufferedInputStream(inputStream); + bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(outputFile)); + checkMagicAndCopyFileTo(bufferedInputStream, bufferedOutputStream); + bufferedOutputStream.flush(); + bufferedOutputStream.close(); + + if (SHOULD_VERIFY_CHECKSUM) { + final String actualRawChecksum = MD5Calculator.checksum( + new BufferedInputStream(new FileInputStream(outputFile))); + Log.i(TAG, "Computed checksum for downloaded dictionary. Expected = " + + rawChecksum + " ; actual = " + actualRawChecksum); + if (!TextUtils.isEmpty(rawChecksum) && !rawChecksum.equals(actualRawChecksum)) { + throw new IOException( + "Could not decode the file correctly : checksum differs"); + } + } + + // move the output file to the final staging file. + final File finalFile = new File(finalFileName); + if (!FileUtils.renameTo(outputFile, finalFile)) { + Log.e(TAG, String.format("Failed to rename from %s to %s.", + outputFile.getAbsoluteFile(), finalFile.getAbsoluteFile())); + } + + wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT, + QUERY_PARAMETER_SUCCESS); + if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) { + Log.e(TAG, "Could not have the dictionary pack delete a word list"); + } + Log.d(TAG, "Successfully copied file for wordlist ID " + wordlistId); + // Success! Close files (through the finally{} clause) and return. + return; + } catch (Exception e) { + if (DEBUG) { + Log.e(TAG, "Can't open word list in mode " + mode, e); + } + if (null != outputFile) { + // This may or may not fail. The file may not have been created if the + // exception was thrown before it could be. Hence, both failure and + // success are expected outcomes, so we don't check the return value. + outputFile.delete(); + } + // Try the next method. + } finally { + // Ignore exceptions while closing files. + closeAssetFileDescriptorAndReportAnyException(afd); + closeCloseableAndReportAnyException(inputStream); + closeCloseableAndReportAnyException(uncompressedStream); + closeCloseableAndReportAnyException(decryptedStream); + closeCloseableAndReportAnyException(bufferedInputStream); + closeCloseableAndReportAnyException(bufferedOutputStream); + } + } + + // We could not copy the file at all. This is very unexpected. + // I'd rather not print the word list ID to the log out of security concerns + Log.e(TAG, "Could not copy a word list. Will not be able to use it."); + // If we can't copy it we should warn the dictionary provider so that it can mark it + // as invalid. + reportBrokenFileToDictionaryProvider(providerClient, clientId, wordlistId); + } + + public static boolean reportBrokenFileToDictionaryProvider( + final ContentProviderClient providerClient, final String clientId, + final String wordlistId) { + try { + final Uri.Builder wordListUriBuilder = getContentUriBuilderForType(clientId, + providerClient, QUERY_PATH_DATAFILE, wordlistId /* extraPath */); + wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT, + QUERY_PARAMETER_FAILURE); + if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) { + Log.e(TAG, "Unable to delete a word list."); + } + } catch (RemoteException e) { + Log.e(TAG, "Communication with the dictionary provider was cut", e); + return false; + } + return true; + } + + // Ideally the two following methods should be merged, but AssetFileDescriptor does not + // implement Closeable although it does implement #close(), and Java does not have + // structural typing. + private static void closeAssetFileDescriptorAndReportAnyException( + final AssetFileDescriptor file) { + try { + if (null != file) file.close(); + } catch (Exception e) { + Log.e(TAG, "Exception while closing a file", e); + } + } + + private static void closeCloseableAndReportAnyException(final Closeable file) { + try { + if (null != file) file.close(); + } catch (Exception e) { + Log.e(TAG, "Exception while closing a file", e); + } + } + + /** + * Queries a content provider for word list data for some locale and stage the returned files + * + * This will query a content provider for word list data for a given locale, and copy the + * files locally so that they can be mmap'ed. This may overwrite previously cached word lists + * with newer versions if a newer version is made available by the content provider. + * @throw FileNotFoundException if the provider returns non-existent data. + * @throw IOException if the provider-returned data could not be read. + */ + public static void installDictToStagingFromContentProvider(final Locale locale, + final Context context, final boolean hasDefaultWordList) { + final ContentProviderClient providerClient; + try { + providerClient = context.getContentResolver(). + acquireContentProviderClient(getProviderUriBuilder("").build()); + } catch (final SecurityException e) { + Log.e(TAG, "No permission to communicate with the dictionary provider", e); + return; + } + if (null == providerClient) { + Log.e(TAG, "Can't establish communication with the dictionary provider"); + return; + } + try { + final List<WordListInfo> idList = getWordListWordListInfos(locale, context, + hasDefaultWordList); + for (WordListInfo id : idList) { + installWordListToStaging(id.mId, id.mLocale, id.mRawChecksum, providerClient, + context); + } + } finally { + providerClient.release(); + } + } + + /** + * Downloads the dictionary if it was never requested/used. + * + * @param locale locale to download + * @param context the context for resources and providers. + * @param hasDefaultWordList whether the default wordlist exists in the resources. + */ + public static void downloadDictIfNeverRequested(final Locale locale, + final Context context, final boolean hasDefaultWordList) { + getWordListWordListInfos(locale, context, hasDefaultWordList); + } + + /** + * Copies the data in an input stream to a target file if the magic number matches. + * + * If the magic number does not match the expected value, this method throws an + * IOException. Other usual conditions for IOException or FileNotFoundException + * also apply. + * + * @param input the stream to be copied. + * @param output an output stream to copy the data to. + */ + public static void checkMagicAndCopyFileTo(final BufferedInputStream input, + final BufferedOutputStream output) throws FileNotFoundException, IOException { + // Check the magic number + final int length = MAGIC_NUMBER_VERSION_2.length; + final byte[] magicNumberBuffer = new byte[length]; + final int readMagicNumberSize = input.read(magicNumberBuffer, 0, length); + if (readMagicNumberSize < length) { + throw new IOException("Less bytes to read than the magic number length"); + } + if (SHOULD_VERIFY_MAGIC_NUMBER) { + if (!Arrays.equals(MAGIC_NUMBER_VERSION_2, magicNumberBuffer)) { + if (!Arrays.equals(MAGIC_NUMBER_VERSION_1, magicNumberBuffer)) { + throw new IOException("Wrong magic number for downloaded file"); + } + } + } + output.write(magicNumberBuffer); + + // Actually copy the file + final byte[] buffer = new byte[FILE_READ_BUFFER_SIZE]; + for (int readBytes = input.read(buffer); readBytes >= 0; readBytes = input.read(buffer)) { + output.write(buffer, 0, readBytes); + } + input.close(); + } + + private static void reinitializeClientRecordInDictionaryContentProvider(final Context context, + final ContentProviderClient client, final String clientId) throws RemoteException { + final String metadataFileUri = MetadataFileUriGetter.getMetadataUri(context); + Log.i(TAG, "reinitializeClientRecordInDictionaryContentProvider() : MetadataFileUri = " + + metadataFileUri); + final String metadataAdditionalId = MetadataFileUriGetter.getMetadataAdditionalId(context); + // Tell the content provider to reset all information about this client id + final Uri metadataContentUri = getProviderUriBuilder(clientId) + .appendPath(QUERY_PATH_METADATA) + .appendQueryParameter(QUERY_PARAMETER_PROTOCOL, QUERY_PARAMETER_PROTOCOL_VALUE) + .build(); + client.delete(metadataContentUri, null, null); + // Update the metadata URI + final ContentValues metadataValues = new ContentValues(); + metadataValues.put(INSERT_METADATA_CLIENT_ID_COLUMN, clientId); + metadataValues.put(INSERT_METADATA_METADATA_URI_COLUMN, metadataFileUri); + metadataValues.put(INSERT_METADATA_METADATA_ADDITIONAL_ID_COLUMN, metadataAdditionalId); + client.insert(metadataContentUri, metadataValues); + + // Update the dictionary list. + final Uri dictionaryContentUriBase = getProviderUriBuilder(clientId) + .appendPath(QUERY_PATH_DICT_INFO) + .appendQueryParameter(QUERY_PARAMETER_PROTOCOL, QUERY_PARAMETER_PROTOCOL_VALUE) + .build(); + final ArrayList<DictionaryInfo> dictionaryList = + DictionaryInfoUtils.getCurrentDictionaryFileNameAndVersionInfo(context); + final int length = dictionaryList.size(); + for (int i = 0; i < length; ++i) { + final DictionaryInfo info = dictionaryList.get(i); + Log.i(TAG, "reinitializeClientRecordInDictionaryContentProvider() : Insert " + info); + client.insert(Uri.withAppendedPath(dictionaryContentUriBase, info.mId), + info.toContentValues()); + } + + // Read from metadata file in resources to get the baseline dictionary info. + // This ensures we start with a valid list of available dictionaries. + final int metadataResourceId = context.getResources().getIdentifier("metadata", + "raw", DictionaryInfoUtils.RESOURCE_PACKAGE_NAME); + if (metadataResourceId == 0) { + Log.w(TAG, "Missing metadata.json resource"); + return; + } + InputStream inputStream = null; + try { + inputStream = context.getResources().openRawResource(metadataResourceId); + UpdateHandler.handleMetadata(context, inputStream, clientId); + } catch (Exception e) { + Log.w(TAG, "Failed to read metadata.json from resources", e); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close metadata.json", e); + } + } + } + } + + /** + * Initialize a client record with the dictionary content provider. + * + * This merely acquires the content provider and calls + * #reinitializeClientRecordInDictionaryContentProvider. + * + * @param context the context for resources and providers. + * @param clientId the client ID to use. + */ + public static void initializeClientRecordHelper(final Context context, final String clientId) { + try { + final ContentProviderClient client = context.getContentResolver(). + acquireContentProviderClient(getProviderUriBuilder("").build()); + if (null == client) return; + reinitializeClientRecordInDictionaryContentProvider(context, client, clientId); + } catch (RemoteException e) { + Log.e(TAG, "Cannot contact the dictionary content provider", e); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/BinaryDictionaryGetter.java b/java/src/org/kelar/inputmethod/latin/BinaryDictionaryGetter.java new file mode 100644 index 000000000..75d2d5d4e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/BinaryDictionaryGetter.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.AssetFileDescriptor; +import android.util.Log; + +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.UnsupportedFormatException; +import org.kelar.inputmethod.latin.utils.BinaryDictionaryUtils; +import org.kelar.inputmethod.latin.utils.DictionaryInfoUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; + +/** + * Helper class to get the address of a mmap'able dictionary file. + */ +final public class BinaryDictionaryGetter { + + /** + * Used for Log actions from this class + */ + private static final String TAG = BinaryDictionaryGetter.class.getSimpleName(); + + /** + * Used to return empty lists + */ + private static final File[] EMPTY_FILE_ARRAY = new File[0]; + + /** + * Name of the common preferences name to know which word list are on and which are off. + */ + private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs"; + + private static final boolean SHOULD_USE_DICT_VERSION = + DecoderSpecificConstants.SHOULD_USE_DICT_VERSION; + + // Name of the category for the main dictionary + public static final String MAIN_DICTIONARY_CATEGORY = "main"; + public static final String ID_CATEGORY_SEPARATOR = ":"; + + // The key considered to read the version attribute in a dictionary file. + private static String VERSION_KEY = "version"; + + // Prevents this from being instantiated + private BinaryDictionaryGetter() {} + + /** + * Generates a unique temporary file name in the app cache directory. + */ + public static String getTempFileName(final String id, final Context context) + throws IOException { + final String safeId = DictionaryInfoUtils.replaceFileNameDangerousCharacters(id); + final File directory = new File(DictionaryInfoUtils.getWordListTempDirectory(context)); + if (!directory.exists()) { + if (!directory.mkdirs()) { + Log.e(TAG, "Could not create the temporary directory"); + } + } + // If the first argument is less than three chars, createTempFile throws a + // RuntimeException. We don't really care about what name we get, so just + // put a three-chars prefix makes us safe. + return File.createTempFile("xxx" + safeId, null, directory).getAbsolutePath(); + } + + /** + * Returns a file address from a resource, or null if it cannot be opened. + */ + public static AssetFileAddress loadFallbackResource(final Context context, + final int fallbackResId) { + AssetFileDescriptor afd = null; + try { + afd = context.getResources().openRawResourceFd(fallbackResId); + } catch (RuntimeException e) { + Log.e(TAG, "Resource not found: " + fallbackResId); + return null; + } + if (afd == null) { + Log.e(TAG, "Resource cannot be opened: " + fallbackResId); + return null; + } + try { + return AssetFileAddress.makeFromFileNameAndOffset( + context.getApplicationInfo().sourceDir, afd.getStartOffset(), afd.getLength()); + } finally { + try { + afd.close(); + } catch (IOException ignored) { + } + } + } + + private static final class DictPackSettings { + final SharedPreferences mDictPreferences; + public DictPackSettings(final Context context) { + mDictPreferences = null == context ? null + : context.getSharedPreferences(COMMON_PREFERENCES_NAME, + Context.MODE_MULTI_PROCESS); + } + public boolean isWordListActive(final String dictId) { + if (null == mDictPreferences) { + // If we don't have preferences it basically means we can't find the dictionary + // pack - either it's not installed, or it's disabled, or there is some strange + // bug. Either way, a word list with no settings should be on by default: default + // dictionaries in LatinIME are on if there is no settings at all, and if for some + // reason some dictionaries have been installed BUT the dictionary pack can't be + // found anymore it's safer to actually supply installed dictionaries. + return true; + } + // The default is true here for the same reasons as above. We got the dictionary + // pack but if we don't have any settings for it it means the user has never been + // to the settings yet. So by default, the main dictionaries should be on. + return mDictPreferences.getBoolean(dictId, true); + } + } + + /** + * Utility class for the {@link #getCachedWordLists} method + */ + private static final class FileAndMatchLevel { + final File mFile; + final int mMatchLevel; + public FileAndMatchLevel(final File file, final int matchLevel) { + mFile = file; + mMatchLevel = matchLevel; + } + } + + /** + * Returns the list of cached files for a specific locale, one for each category. + * + * This will return exactly one file for each word list category that matches + * the passed locale. If several files match the locale for any given category, + * this returns the file with the closest match to the locale. For example, if + * the passed word list is en_US, and for a category we have an en and an en_US + * word list available, we'll return only the en_US one. + * Thus, the list will contain as many files as there are categories. + * + * @param locale the locale to find the dictionary files for, as a string. + * @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. + */ + public 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 = new HashMap<>(); + for (File directory : directoryList) { + if (!directory.isDirectory()) continue; + 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 = + DictionaryInfoUtils.getCategoryFromFileName(wordList.getName()); + final FileAndMatchLevel currentBestMatch = cacheFiles.get(category); + if (null == currentBestMatch || currentBestMatch.mMatchLevel < matchLevel) { + cacheFiles.put(category, new FileAndMatchLevel(wordList, matchLevel)); + } + } + } + } + } + if (cacheFiles.isEmpty()) return EMPTY_FILE_ARRAY; + final File[] result = new File[cacheFiles.size()]; + int index = 0; + for (final FileAndMatchLevel entry : cacheFiles.values()) { + result[index++] = entry.mFile; + } + return result; + } + + // ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since + // those do not include allowlist entries, the new code with an old version of the dictionary + // would lose allowlist functionality. + private static boolean hackCanUseDictionaryFile(final File file) { + if (!SHOULD_USE_DICT_VERSION) { + return true; + } + + try { + // Read the version of the file + final DictionaryHeader header = BinaryDictionaryUtils.getHeader(file); + final String version = header.mDictionaryOptions.mAttributes.get(VERSION_KEY); + if (null == version) { + // No version in the options : the format is unexpected + return false; + } + // Version 18 is the first one to include the allowlist. + // Obviously this is a big ## HACK ## + return Integer.parseInt(version) >= 18; + } catch (java.io.FileNotFoundException e) { + return false; + } catch (java.io.IOException e) { + return false; + } catch (NumberFormatException e) { + return false; + } catch (BufferUnderflowException e) { + return false; + } catch (UnsupportedFormatException e) { + return false; + } + } + + /** + * Returns a list of file addresses for a given locale, trying relevant methods in order. + * + * Tries to get binary dictionaries from various sources, in order: + * - Uses a content provider to get a public dictionary set, as per the protocol described + * in BinaryDictionaryFileDumper. + * If that fails: + * - Gets a file name from the built-in dictionary for this locale, if any. + * If that fails: + * - Returns null. + * @return The list of addresses of valid dictionary files, or null. + */ + public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale, + final Context context, boolean notifyDictionaryPackForUpdates) { + if (notifyDictionaryPackForUpdates) { + final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable( + context, locale); + // It makes sure that the first time keyboard comes up and the dictionaries are reset, + // the DB is populated with the appropriate values for each locale. Helps in downloading + // the dictionaries when the user enables and switches new languages before the + // DictionaryService runs. + BinaryDictionaryFileDumper.downloadDictIfNeverRequested( + locale, context, hasDefaultWordList); + + // Move a staging files to the cache ddirectories if any. + DictionaryInfoUtils.moveStagingFilesIfExists(context); + } + final File[] cachedWordLists = getCachedWordLists(locale.toString(), context); + final String mainDictId = DictionaryInfoUtils.getMainDictId(locale); + final DictPackSettings dictPackSettings = new DictPackSettings(context); + + boolean foundMainDict = false; + final ArrayList<AssetFileAddress> fileList = new ArrayList<>(); + // cachedWordLists may not be null, see doc for getCachedDictionaryList + for (final File f : cachedWordLists) { + final String wordListId = DictionaryInfoUtils.getWordListIdFromFileName(f.getName()); + final boolean canUse = f.canRead() && hackCanUseDictionaryFile(f); + if (canUse && DictionaryInfoUtils.isMainWordListId(wordListId)) { + foundMainDict = true; + } + if (!dictPackSettings.isWordListActive(wordListId)) continue; + if (canUse) { + final AssetFileAddress afa = AssetFileAddress.makeFromFileName(f.getPath()); + if (null != afa) fileList.add(afa); + } else { + Log.e(TAG, "Found a cached dictionary file for " + locale.toString() + + " but cannot read or use it"); + } + } + + if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) { + final int fallbackResId = + DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale); + final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId); + if (null != fallbackAsset) { + fileList.add(fallbackAsset); + } + } + + return fileList; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ContactsBinaryDictionary.java b/java/src/org/kelar/inputmethod/latin/ContactsBinaryDictionary.java new file mode 100644 index 000000000..97f465095 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ContactsBinaryDictionary.java @@ -0,0 +1,176 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.Manifest; +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.util.Log; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.latin.ContactsManager.ContactsChangedListener; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.personalization.AccountUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import javax.annotation.Nullable; + +public class ContactsBinaryDictionary extends ExpandableBinaryDictionary + implements ContactsChangedListener { + private static final String TAG = ContactsBinaryDictionary.class.getSimpleName(); + private static final String NAME = "contacts"; + + private static final boolean DEBUG = false; + private static final boolean DEBUG_DUMP = false; + + /** + * Whether to use "firstname lastname" in bigram predictions. + */ + private final boolean mUseFirstLastBigrams; + private final ContactsManager mContactsManager; + + protected ContactsBinaryDictionary(final Context context, final Locale locale, + final File dictFile, final String name) { + super(context, getDictName(name, locale, dictFile), locale, Dictionary.TYPE_CONTACTS, + dictFile); + mUseFirstLastBigrams = ContactsDictionaryUtils.useFirstLastBigramsForLocale(locale); + mContactsManager = new ContactsManager(context); + mContactsManager.registerForUpdates(this /* listener */); + reloadDictionaryIfRequired(); + } + + // Note: This method is called by {@link DictionaryFacilitator} using Java reflection. + @ExternallyReferenced + public static ContactsBinaryDictionary getDictionary(final Context context, final Locale locale, + final File dictFile, final String dictNamePrefix, @Nullable final String account) { + return new ContactsBinaryDictionary(context, locale, dictFile, dictNamePrefix + NAME); + } + + @Override + public synchronized void close() { + mContactsManager.close(); + super.close(); + } + + /** + * Typically called whenever the dictionary is created for the first time or + * recreated when we think that there are updates to the dictionary. + * This is called asynchronously. + */ + @Override + public void loadInitialContentsLocked() { + loadDeviceAccountsEmailAddressesLocked(); + loadDictionaryForUriLocked(ContactsContract.Profile.CONTENT_URI); + // TODO: Switch this URL to the newer ContactsContract too + loadDictionaryForUriLocked(Contacts.CONTENT_URI); + } + + /** + * Loads device accounts to the dictionary. + */ + private void loadDeviceAccountsEmailAddressesLocked() { + final List<String> accountVocabulary = + AccountUtils.getDeviceAccountsEmailAddresses(mContext); + if (accountVocabulary == null || accountVocabulary.isEmpty()) { + return; + } + for (String word : accountVocabulary) { + if (DEBUG) { + Log.d(TAG, "loadAccountVocabulary: " + word); + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addUnigramLocked(word, ContactsDictionaryConstants.FREQUENCY_FOR_CONTACTS, + false /* isNotAWord */, false /* isPossiblyOffensive */, + BinaryDictionary.NOT_A_VALID_TIMESTAMP); + } + } + + /** + * Loads data within content providers to the dictionary. + */ + private void loadDictionaryForUriLocked(final Uri uri) { + if (!PermissionsUtil.checkAllPermissionsGranted( + mContext, Manifest.permission.READ_CONTACTS)) { + Log.i(TAG, "No permission to read contacts. Not loading the Dictionary."); + } + + final ArrayList<String> validNames = mContactsManager.getValidNames(uri); + for (final String name : validNames) { + addNameLocked(name); + } + if (uri.equals(Contacts.CONTENT_URI)) { + // Since we were able to add content successfully, update the local + // state of the manager. + mContactsManager.updateLocalState(validNames); + } + } + + /** + * Adds the words in a name (e.g., firstname/lastname) to the binary dictionary along with their + * bigrams depending on locale. + */ + private void addNameLocked(final String name) { + int len = StringUtils.codePointCount(name); + NgramContext ngramContext = NgramContext.getEmptyPrevWordsContext( + BinaryDictionary.MAX_PREV_WORD_COUNT_FOR_N_GRAM); + // TODO: Better tokenization for non-Latin writing systems + for (int i = 0; i < len; i++) { + if (Character.isLetter(name.codePointAt(i))) { + int end = ContactsDictionaryUtils.getWordEndPosition(name, len, i); + String word = name.substring(i, end); + if (DEBUG_DUMP) { + Log.d(TAG, "addName word = " + word); + } + i = end - 1; + // Don't add single letter words, possibly confuses + // capitalization of i. + final int wordLen = StringUtils.codePointCount(word); + if (wordLen <= MAX_WORD_LENGTH && wordLen > 1) { + if (DEBUG) { + Log.d(TAG, "addName " + name + ", " + word + ", " + ngramContext); + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addUnigramLocked(word, + ContactsDictionaryConstants.FREQUENCY_FOR_CONTACTS, false /* isNotAWord */, + false /* isPossiblyOffensive */, + BinaryDictionary.NOT_A_VALID_TIMESTAMP); + if (ngramContext.isValid() && mUseFirstLastBigrams) { + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addNgramEntryLocked(ngramContext, + word, + ContactsDictionaryConstants.FREQUENCY_FOR_CONTACTS_BIGRAM, + BinaryDictionary.NOT_A_VALID_TIMESTAMP); + } + ngramContext = ngramContext.getNextNgramContext( + new NgramContext.WordInfo(word)); + } + } + } + } + + @Override + public void onContactsChange() { + setNeedsToRecreate(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ContactsContentObserver.java b/java/src/org/kelar/inputmethod/latin/ContactsContentObserver.java new file mode 100644 index 000000000..693675354 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ContactsContentObserver.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.os.SystemClock; +import android.provider.ContactsContract.Contacts; +import android.util.Log; + +import org.kelar.inputmethod.latin.ContactsManager.ContactsChangedListener; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.utils.ExecutorUtils; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A content observer that listens to updates to content provider {@link Contacts#CONTENT_URI}. + */ +public class ContactsContentObserver implements Runnable { + private static final String TAG = "ContactsContentObserver"; + + private final Context mContext; + private final ContactsManager mManager; + private final AtomicBoolean mRunning = new AtomicBoolean(false); + + private ContentObserver mContentObserver; + private ContactsChangedListener mContactsChangedListener; + + public ContactsContentObserver(final ContactsManager manager, final Context context) { + mManager = manager; + mContext = context; + } + + public void registerObserver(final ContactsChangedListener listener) { + if (!PermissionsUtil.checkAllPermissionsGranted( + mContext, Manifest.permission.READ_CONTACTS)) { + Log.i(TAG, "No permission to read contacts. Not registering the observer."); + // do nothing if we do not have the permission to read contacts. + return; + } + + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "registerObserver()"); + } + mContactsChangedListener = listener; + mContentObserver = new ContentObserver(null /* handler */) { + @Override + public void onChange(boolean self) { + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD) + .execute(ContactsContentObserver.this); + } + }; + final ContentResolver contentResolver = mContext.getContentResolver(); + contentResolver.registerContentObserver(Contacts.CONTENT_URI, true, mContentObserver); + } + + @Override + public void run() { + if (!PermissionsUtil.checkAllPermissionsGranted( + mContext, Manifest.permission.READ_CONTACTS)) { + Log.i(TAG, "No permission to read contacts. Not updating the contacts."); + unregister(); + return; + } + + if (!mRunning.compareAndSet(false /* expect */, true /* update */)) { + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "run() : Already running. Don't waste time checking again."); + } + return; + } + if (haveContentsChanged()) { + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "run() : Contacts have changed. Notifying listeners."); + } + mContactsChangedListener.onContactsChange(); + } + mRunning.set(false); + } + + boolean haveContentsChanged() { + if (!PermissionsUtil.checkAllPermissionsGranted( + mContext, Manifest.permission.READ_CONTACTS)) { + Log.i(TAG, "No permission to read contacts. Marking contacts as not changed."); + return false; + } + + final long startTime = SystemClock.uptimeMillis(); + final int contactCount = mManager.getContactCount(); + if (contactCount > ContactsDictionaryConstants.MAX_CONTACTS_PROVIDER_QUERY_LIMIT) { + // If there are too many contacts then return false. In this rare case it is impossible + // to include all of them anyways and the cost of rebuilding the dictionary is too high. + // TODO: Sort and check only the most recent contacts? + return false; + } + if (contactCount != mManager.getContactCountAtLastRebuild()) { + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "haveContentsChanged() : Count changed from " + + mManager.getContactCountAtLastRebuild() + " to " + contactCount); + } + return true; + } + final ArrayList<String> names = mManager.getValidNames(Contacts.CONTENT_URI); + if (names.hashCode() != mManager.getHashCodeAtLastRebuild()) { + return true; + } + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "haveContentsChanged() : No change detected in " + + (SystemClock.uptimeMillis() - startTime) + " ms)"); + } + return false; + } + + public void unregister() { + mContext.getContentResolver().unregisterContentObserver(mContentObserver); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ContactsDictionaryConstants.java b/java/src/org/kelar/inputmethod/latin/ContactsDictionaryConstants.java new file mode 100644 index 000000000..f4d256787 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ContactsDictionaryConstants.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin; + +import android.provider.BaseColumns; +import android.provider.ContactsContract.Contacts; + +/** + * Constants related to Contacts Content Provider. + */ +public class ContactsDictionaryConstants { + /** + * Projections for {@link Contacts.CONTENT_URI} + */ + public static final String[] PROJECTION = { BaseColumns._ID, Contacts.DISPLAY_NAME, + Contacts.TIMES_CONTACTED, Contacts.LAST_TIME_CONTACTED, Contacts.IN_VISIBLE_GROUP }; + public static final String[] PROJECTION_ID_ONLY = { BaseColumns._ID }; + + /** + * Frequency for contacts information into the dictionary + */ + public static final int FREQUENCY_FOR_CONTACTS = 40; + public static final int FREQUENCY_FOR_CONTACTS_BIGRAM = 90; + + /** + * Do not attempt to query contacts if there are more than this many entries. + */ + public static final int MAX_CONTACTS_PROVIDER_QUERY_LIMIT = 10000; + + /** + * Index of the column for 'name' in content providers: + * Contacts & ContactsContract.Profile. + */ + public static final int NAME_INDEX = 1; + public static final int TIMES_CONTACTED_INDEX = 2; + public static final int LAST_TIME_CONTACTED_INDEX = 3; + public static final int IN_VISIBLE_GROUP_INDEX = 4; +} diff --git a/java/src/org/kelar/inputmethod/latin/ContactsDictionaryUtils.java b/java/src/org/kelar/inputmethod/latin/ContactsDictionaryUtils.java new file mode 100644 index 000000000..1db81503d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ContactsDictionaryUtils.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.latin.common.Constants; + +import java.util.Locale; + +/** + * Utility methods related contacts dictionary. + */ +public class ContactsDictionaryUtils { + + /** + * Returns the index of the last letter in the word, starting from position startIndex. + */ + public static int getWordEndPosition(final String string, final int len, + final int startIndex) { + int end; + int cp = 0; + for (end = startIndex + 1; end < len; end += Character.charCount(cp)) { + cp = string.codePointAt(end); + if (cp != Constants.CODE_DASH && cp != Constants.CODE_SINGLE_QUOTE + && !Character.isLetter(cp)) { + break; + } + } + return end; + } + + /** + * Returns true if the locale supports using first name and last name as bigrams. + */ + public static boolean useFirstLastBigramsForLocale(final Locale locale) { + // TODO: Add firstname/lastname bigram rules for other languages. + if (locale != null && locale.getLanguage().equals(Locale.ENGLISH.getLanguage())) { + return true; + } + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ContactsManager.java b/java/src/org/kelar/inputmethod/latin/ContactsManager.java new file mode 100644 index 000000000..e4a6912db --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ContactsManager.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.provider.ContactsContract.Contacts; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.latin.common.Constants; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Manages all interactions with Contacts DB. + * + * The manager provides an API for listening to meaning full updates by keeping a + * measure of the current state of the content provider. + */ +public class ContactsManager { + private static final String TAG = "ContactsManager"; + + /** + * Use at most this many of the highest affinity contacts. + */ + public static final int MAX_CONTACT_NAMES = 200; + + protected static class RankedContact { + public final String mName; + public final long mLastContactedTime; + public final int mTimesContacted; + public final boolean mInVisibleGroup; + + private float mAffinity = 0.0f; + + RankedContact(final Cursor cursor) { + mName = cursor.getString( + ContactsDictionaryConstants.NAME_INDEX); + mTimesContacted = cursor.getInt( + ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); + mLastContactedTime = cursor.getLong( + ContactsDictionaryConstants.LAST_TIME_CONTACTED_INDEX); + mInVisibleGroup = cursor.getInt( + ContactsDictionaryConstants.IN_VISIBLE_GROUP_INDEX) == 1; + } + + float getAffinity() { + return mAffinity; + } + + /** + * Calculates the affinity with the contact based on: + * - How many times it has been contacted + * - How long since the last contact. + * - Whether the contact is in the visible group (i.e., Contacts list). + * + * Note: This affinity is limited by the fact that some apps currently do not update the + * LAST_TIME_CONTACTED or TIMES_CONTACTED counters. As a result, a frequently messaged + * contact may still have 0 affinity. + */ + void computeAffinity(final int maxTimesContacted, final long currentTime) { + final float timesWeight = ((float) mTimesContacted + 1) / (maxTimesContacted + 1); + final long timeSinceLastContact = Math.min( + Math.max(0, currentTime - mLastContactedTime), + TimeUnit.MILLISECONDS.convert(180, TimeUnit.DAYS)); + final float lastTimeWeight = (float) Math.pow(0.5, + timeSinceLastContact / (TimeUnit.MILLISECONDS.convert(10, TimeUnit.DAYS))); + final float visibleWeight = mInVisibleGroup ? 1.0f : 0.0f; + mAffinity = (timesWeight + lastTimeWeight + visibleWeight) / 3; + } + } + + private static class AffinityComparator implements Comparator<RankedContact> { + @Override + public int compare(RankedContact contact1, RankedContact contact2) { + return Float.compare(contact2.getAffinity(), contact1.getAffinity()); + } + } + + /** + * Interface to implement for classes interested in getting notified for updates + * to Contacts content provider. + */ + public static interface ContactsChangedListener { + public void onContactsChange(); + } + + /** + * The number of contacts observed in the most recent instance of + * contacts content provider. + */ + private AtomicInteger mContactCountAtLastRebuild = new AtomicInteger(0); + + /** + * The hash code of list of valid contacts names in the most recent dictionary + * rebuild. + */ + private AtomicInteger mHashCodeAtLastRebuild = new AtomicInteger(0); + + private final Context mContext; + private final ContactsContentObserver mObserver; + + public ContactsManager(final Context context) { + mContext = context; + mObserver = new ContactsContentObserver(this /* ContactsManager */, context); + } + + // TODO: This was synchronized in previous version. Why? + public void registerForUpdates(final ContactsChangedListener listener) { + mObserver.registerObserver(listener); + } + + public int getContactCountAtLastRebuild() { + return mContactCountAtLastRebuild.get(); + } + + public int getHashCodeAtLastRebuild() { + return mHashCodeAtLastRebuild.get(); + } + + /** + * Returns all the valid names in the Contacts DB. Callers should also + * call {@link #updateLocalState(ArrayList)} after they are done with result + * so that the manager can cache local state for determining updates. + * + * These names are sorted by their affinity to the user, with favorite + * contacts appearing first. + */ + public ArrayList<String> getValidNames(final Uri uri) { + // Check all contacts since it's not possible to find out which names have changed. + // This is needed because it's possible to receive extraneous onChange events even when no + // name has changed. + final Cursor cursor = mContext.getContentResolver().query(uri, + ContactsDictionaryConstants.PROJECTION, null, null, null); + final ArrayList<RankedContact> contacts = new ArrayList<>(); + int maxTimesContacted = 0; + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + while (!cursor.isAfterLast()) { + final String name = cursor.getString( + ContactsDictionaryConstants.NAME_INDEX); + if (isValidName(name)) { + final int timesContacted = cursor.getInt( + ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); + if (timesContacted > maxTimesContacted) { + maxTimesContacted = timesContacted; + } + contacts.add(new RankedContact(cursor)); + } + cursor.moveToNext(); + } + } + } finally { + cursor.close(); + } + } + final long currentTime = System.currentTimeMillis(); + for (RankedContact contact : contacts) { + contact.computeAffinity(maxTimesContacted, currentTime); + } + Collections.sort(contacts, new AffinityComparator()); + final HashSet<String> names = new HashSet<>(); + for (int i = 0; i < contacts.size() && names.size() < MAX_CONTACT_NAMES; ++i) { + names.add(contacts.get(i).mName); + } + return new ArrayList<>(names); + } + + /** + * Returns the number of contacts in contacts content provider. + */ + public int getContactCount() { + // TODO: consider switching to a rawQuery("select count(*)...") on the database if + // performance is a bottleneck. + Cursor cursor = null; + try { + cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, + ContactsDictionaryConstants.PROJECTION_ID_ONLY, null, null, null); + if (null == cursor) { + return 0; + } + return cursor.getCount(); + } catch (final SQLiteException e) { + Log.e(TAG, "SQLiteException in the remote Contacts process.", e); + } finally { + if (null != cursor) { + cursor.close(); + } + } + return 0; + } + + private static boolean isValidName(final String name) { + if (TextUtils.isEmpty(name) || name.indexOf(Constants.CODE_COMMERCIAL_AT) != -1) { + return false; + } + final boolean hasSpace = name.indexOf(Constants.CODE_SPACE) != -1; + if (!hasSpace) { + // Only allow an isolated word if it does not contain a hyphen. + // This helps to filter out mailing lists. + return name.indexOf(Constants.CODE_DASH) == -1; + } + return true; + } + + /** + * Updates the local state of the manager. This should be called when the callers + * are done with all the updates of the content provider successfully. + */ + public void updateLocalState(final ArrayList<String> names) { + mContactCountAtLastRebuild.set(getContactCount()); + mHashCodeAtLastRebuild.set(names.hashCode()); + } + + /** + * Performs any necessary cleanup. + */ + public void close() { + mObserver.unregister(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DicTraverseSession.java b/java/src/org/kelar/inputmethod/latin/DicTraverseSession.java new file mode 100644 index 000000000..c95020ae4 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DicTraverseSession.java @@ -0,0 +1,98 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.latin.common.NativeSuggestOptions; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.utils.JniUtils; + +import java.util.Locale; + +public final class DicTraverseSession { + static { + JniUtils.loadNativeLibrary(); + } + // Must be equal to MAX_RESULTS in native/jni/src/defines.h + private static final int MAX_RESULTS = 18; + public final int[] mInputCodePoints = + new int[DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH]; + public final int[][] mPrevWordCodePointArrays = + new int[DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM][]; + public final boolean[] mIsBeginningOfSentenceArray = + new boolean[DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM]; + public final int[] mOutputSuggestionCount = new int[1]; + public final int[] mOutputCodePoints = + new int[DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH * MAX_RESULTS]; + public final int[] mSpaceIndices = new int[MAX_RESULTS]; + public final int[] mOutputScores = new int[MAX_RESULTS]; + public final int[] mOutputTypes = new int[MAX_RESULTS]; + // Only one result is ever used + public final int[] mOutputAutoCommitFirstWordConfidence = new int[1]; + public final float[] mInputOutputWeightOfLangModelVsSpatialModel = new float[1]; + + public final NativeSuggestOptions mNativeSuggestOptions = new NativeSuggestOptions(); + + private static native long setDicTraverseSessionNative(String locale, long dictSize); + private static native void initDicTraverseSessionNative(long nativeDicTraverseSession, + long dictionary, int[] previousWord, int previousWordLength); + private static native void releaseDicTraverseSessionNative(long nativeDicTraverseSession); + + private long mNativeDicTraverseSession; + + public DicTraverseSession(Locale locale, long dictionary, long dictSize) { + mNativeDicTraverseSession = createNativeDicTraverseSession( + locale != null ? locale.toString() : "", dictSize); + initSession(dictionary); + } + + public long getSession() { + return mNativeDicTraverseSession; + } + + public void initSession(long dictionary) { + initSession(dictionary, null, 0); + } + + public void initSession(long dictionary, int[] previousWord, int previousWordLength) { + initDicTraverseSessionNative( + mNativeDicTraverseSession, dictionary, previousWord, previousWordLength); + } + + private static long createNativeDicTraverseSession(String locale, long dictSize) { + return setDicTraverseSessionNative(locale, dictSize); + } + + private void closeInternal() { + if (mNativeDicTraverseSession != 0) { + releaseDicTraverseSessionNative(mNativeDicTraverseSession); + mNativeDicTraverseSession = 0; + } + } + + public void close() { + closeInternal(); + } + + @Override + protected void finalize() throws Throwable { + try { + closeInternal(); + } finally { + super.finalize(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/Dictionary.java b/java/src/org/kelar/inputmethod/latin/Dictionary.java new file mode 100644 index 000000000..e070c428e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/Dictionary.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.Arrays; +import java.util.HashSet; + +/** + * Abstract base class for a dictionary that can do a fuzzy search for words based on a set of key + * strokes. + */ +public abstract class Dictionary { + public static final int NOT_A_PROBABILITY = -1; + public static final float NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL = -1.0f; + + // The following types do not actually come from real dictionary instances, so we create + // corresponding instances. + public static final String TYPE_USER_TYPED = "user_typed"; + public static final PhonyDictionary DICTIONARY_USER_TYPED = new PhonyDictionary(TYPE_USER_TYPED); + + public static final String TYPE_USER_SHORTCUT = "user_shortcut"; + public static final PhonyDictionary DICTIONARY_USER_SHORTCUT = + new PhonyDictionary(TYPE_USER_SHORTCUT); + + public static final String TYPE_APPLICATION_DEFINED = "application_defined"; + public static final PhonyDictionary DICTIONARY_APPLICATION_DEFINED = + new PhonyDictionary(TYPE_APPLICATION_DEFINED); + + public static final String TYPE_HARDCODED = "hardcoded"; // punctuation signs and such + public static final PhonyDictionary DICTIONARY_HARDCODED = + new PhonyDictionary(TYPE_HARDCODED); + + // Spawned by resuming suggestions. Comes from a span that was in the TextView. + public static final String TYPE_RESUMED = "resumed"; + public static final PhonyDictionary DICTIONARY_RESUMED = new PhonyDictionary(TYPE_RESUMED); + + // The following types of dictionary have actual functional instances. We don't need final + // phony dictionary instances for them. + public static final String TYPE_MAIN = "main"; + public static final String TYPE_CONTACTS = "contacts"; + // User dictionary, the system-managed one. + public static final String TYPE_USER = "user"; + // User history dictionary internal to LatinIME. + public static final String TYPE_USER_HISTORY = "history"; + public final String mDictType; + // The locale for this dictionary. May be null if unknown (phony dictionary for example). + public final Locale mLocale; + + /** + * Set out of the dictionary types listed above that are based on data specific to the user, + * e.g., the user's contacts. + */ + private static final HashSet<String> sUserSpecificDictionaryTypes = new HashSet<>(Arrays.asList( + TYPE_USER_TYPED, + TYPE_USER, + TYPE_CONTACTS, + TYPE_USER_HISTORY)); + + public Dictionary(final String dictType, final Locale locale) { + mDictType = dictType; + mLocale = locale; + } + + /** + * Searches for suggestions for a given context. + * @param composedData the key sequence to match with coordinate info + * @param ngramContext the context for n-gram. + * @param proximityInfoHandle the handle for key proximity. Is ignored by some implementations. + * @param settingsValuesForSuggestion the settings values used for the suggestion. + * @param sessionId the session id. + * @param weightForLocale the weight given to this locale, to multiply the output scores for + * multilingual input. + * @param inOutWeightOfLangModelVsSpatialModel the weight of the language model as a ratio of + * the spatial model, used for generating suggestions. inOutWeightOfLangModelVsSpatialModel is + * a float array that has only one element. This can be updated when a different value is used. + * @return the list of suggestions (possibly null if none) + */ + abstract public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int sessionId, final float weightForLocale, + final float[] inOutWeightOfLangModelVsSpatialModel); + + /** + * Checks if the given word has to be treated as a valid word. Please note that some + * dictionaries have entries that should be treated as invalid words. + * @param word the word to search for. The search should be case-insensitive. + * @return true if the word is valid, false otherwise + */ + public boolean isValidWord(final String word) { + return isInDictionary(word); + } + + /** + * Checks if the given word is in the dictionary regardless of it being valid or not. + */ + abstract public boolean isInDictionary(final String word); + + /** + * Get the frequency of the word. + * @param word the word to get the frequency of. + */ + public int getFrequency(final String word) { + return NOT_A_PROBABILITY; + } + + /** + * Get the maximum frequency of the word. + * @param word the word to get the maximum frequency of. + */ + public int getMaxFrequencyOfExactMatches(final String word) { + return NOT_A_PROBABILITY; + } + + /** + * Compares the contents of the character array with the typed word and returns true if they + * are the same. + * @param word the array of characters that make up the word + * @param length the number of valid characters in the character array + * @param typedWord the word to compare with + * @return true if they are the same, false otherwise. + */ + protected boolean same(final char[] word, final int length, final String typedWord) { + if (typedWord.length() != length) { + return false; + } + for (int i = 0; i < length; i++) { + if (word[i] != typedWord.charAt(i)) { + return false; + } + } + return true; + } + + /** + * Override to clean up any resources. + */ + public void close() { + // empty base implementation + } + + /** + * Subclasses may override to indicate that this Dictionary is not yet properly initialized. + */ + public boolean isInitialized() { + return true; + } + + /** + * Whether we think this suggestion should trigger an auto-commit. prevWord is the word + * before the suggestion, so that we can use n-gram frequencies. + * @param candidate The candidate suggestion, in whole (not only the first part). + * @return whether we should auto-commit or not. + */ + public boolean shouldAutoCommit(final SuggestedWordInfo candidate) { + // If we don't have support for auto-commit, or if we don't know, we return false to + // avoid auto-committing stuff. Implementations of the Dictionary class that know to + // determine whether we should auto-commit will override this. + return false; + } + + /** + * Whether this dictionary is based on data specific to the user, e.g., the user's contacts. + * @return Whether this dictionary is specific to the user. + */ + public boolean isUserSpecific() { + return sUserSpecificDictionaryTypes.contains(mDictType); + } + + /** + * Not a true dictionary. A placeholder used to indicate suggestions that don't come from any + * real dictionary. + */ + @UsedForTesting + static class PhonyDictionary extends Dictionary { + @UsedForTesting + PhonyDictionary(final String type) { + super(type, null); + } + + @Override + public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int sessionId, final float weightForLocale, + final float[] inOutWeightOfLangModelVsSpatialModel) { + return null; + } + + @Override + public boolean isInDictionary(String word) { + return false; + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryCollection.java b/java/src/org/kelar/inputmethod/latin/DictionaryCollection.java new file mode 100644 index 000000000..16affc317 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryCollection.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import android.util.Log; + +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Class for a collection of dictionaries that behave like one dictionary. + */ +public final class DictionaryCollection extends Dictionary { + private final String TAG = DictionaryCollection.class.getSimpleName(); + protected final CopyOnWriteArrayList<Dictionary> mDictionaries; + + public DictionaryCollection(final String dictType, final Locale locale) { + super(dictType, locale); + mDictionaries = new CopyOnWriteArrayList<>(); + } + + public DictionaryCollection(final String dictType, final Locale locale, + final Dictionary... dictionaries) { + super(dictType, locale); + if (null == dictionaries) { + mDictionaries = new CopyOnWriteArrayList<>(); + } else { + mDictionaries = new CopyOnWriteArrayList<>(dictionaries); + mDictionaries.removeAll(Collections.singleton(null)); + } + } + + public DictionaryCollection(final String dictType, final Locale locale, + final Collection<Dictionary> dictionaries) { + super(dictType, locale); + mDictionaries = new CopyOnWriteArrayList<>(dictionaries); + mDictionaries.removeAll(Collections.singleton(null)); + } + + @Override + public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int sessionId, final float weightForLocale, + final float[] inOutWeightOfLangModelVsSpatialModel) { + final CopyOnWriteArrayList<Dictionary> dictionaries = mDictionaries; + if (dictionaries.isEmpty()) return null; + // To avoid creating unnecessary objects, we get the list out of the first + // dictionary and add the rest to it if not null, hence the get(0) + ArrayList<SuggestedWordInfo> suggestions = dictionaries.get(0).getSuggestions(composedData, + ngramContext, proximityInfoHandle, settingsValuesForSuggestion, sessionId, + weightForLocale, inOutWeightOfLangModelVsSpatialModel); + if (null == suggestions) suggestions = new ArrayList<>(); + final int length = dictionaries.size(); + for (int i = 1; i < length; ++ i) { + final ArrayList<SuggestedWordInfo> sugg = dictionaries.get(i).getSuggestions( + composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, + sessionId, weightForLocale, inOutWeightOfLangModelVsSpatialModel); + if (null != sugg) suggestions.addAll(sugg); + } + return suggestions; + } + + @Override + public boolean isInDictionary(final String word) { + for (int i = mDictionaries.size() - 1; i >= 0; --i) + if (mDictionaries.get(i).isInDictionary(word)) return true; + return false; + } + + @Override + public int getFrequency(final String word) { + int maxFreq = -1; + for (int i = mDictionaries.size() - 1; i >= 0; --i) { + final int tempFreq = mDictionaries.get(i).getFrequency(word); + maxFreq = Math.max(tempFreq, maxFreq); + } + return maxFreq; + } + + @Override + public int getMaxFrequencyOfExactMatches(final String word) { + int maxFreq = -1; + for (int i = mDictionaries.size() - 1; i >= 0; --i) { + final int tempFreq = mDictionaries.get(i).getMaxFrequencyOfExactMatches(word); + maxFreq = Math.max(tempFreq, maxFreq); + } + return maxFreq; + } + + @Override + public boolean isInitialized() { + return !mDictionaries.isEmpty(); + } + + @Override + public void close() { + for (final Dictionary dict : mDictionaries) + dict.close(); + } + + // Warning: this is not thread-safe. Take necessary precaution when calling. + public void addDictionary(final Dictionary newDict) { + if (null == newDict) return; + if (mDictionaries.contains(newDict)) { + Log.w(TAG, "This collection already contains this dictionary: " + newDict); + } + mDictionaries.add(newDict); + } + + // Warning: this is not thread-safe. Take necessary precaution when calling. + public void removeDictionary(final Dictionary dict) { + if (mDictionaries.contains(dict)) { + mDictionaries.remove(dict); + } else { + Log.w(TAG, "This collection does not contain this dictionary: " + dict); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryDumpBroadcastReceiver.java b/java/src/org/kelar/inputmethod/latin/DictionaryDumpBroadcastReceiver.java new file mode 100644 index 000000000..56f4215bb --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryDumpBroadcastReceiver.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class DictionaryDumpBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = DictionaryDumpBroadcastReceiver.class.getSimpleName(); + + private static final String DOMAIN = "org.kelar.inputmethod.latin"; + public static final String DICTIONARY_DUMP_INTENT_ACTION = DOMAIN + ".DICT_DUMP"; + public static final String DICTIONARY_NAME_KEY = "dictName"; + + final LatinIME mLatinIme; + + public DictionaryDumpBroadcastReceiver(final LatinIME latinIme) { + mLatinIme = latinIme; + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (action.equals(DICTIONARY_DUMP_INTENT_ACTION)) { + final String dictName = intent.getStringExtra(DICTIONARY_NAME_KEY); + if (dictName == null) { + Log.e(TAG, "Received dictionary dump intent action " + + "but the dictionary name is not set."); + return; + } + mLatinIme.dumpDictionaryForDebug(dictName); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryFacilitator.java b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitator.java new file mode 100644 index 000000000..319015c90 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitator.java @@ -0,0 +1,176 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.util.LruCache; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.SuggestionResults; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Interface that facilitates interaction with different kinds of dictionaries. Provides APIs to + * instantiate and select the correct dictionaries (based on language or account), update entries + * and fetch suggestions. Currently AndroidSpellCheckerService and LatinIME both use + * DictionaryFacilitator as a client for interacting with dictionaries. + */ +public interface DictionaryFacilitator { + + public static final String[] ALL_DICTIONARY_TYPES = new String[] { + Dictionary.TYPE_MAIN, + Dictionary.TYPE_CONTACTS, + Dictionary.TYPE_USER_HISTORY, + Dictionary.TYPE_USER}; + + public static final String[] DYNAMIC_DICTIONARY_TYPES = new String[] { + Dictionary.TYPE_CONTACTS, + Dictionary.TYPE_USER_HISTORY, + Dictionary.TYPE_USER}; + + /** + * The facilitator will put words into the cache whenever it decodes them. + * @param cache + */ + void setValidSpellingWordReadCache(final LruCache<String, Boolean> cache); + + /** + * The facilitator will get words from the cache whenever it needs to check their spelling. + * @param cache + */ + void setValidSpellingWordWriteCache(final LruCache<String, Boolean> cache); + + /** + * Returns whether this facilitator is exactly for this locale. + * + * @param locale the locale to test against + */ + boolean isForLocale(final Locale locale); + + /** + * Returns whether this facilitator is exactly for this account. + * + * @param account the account to test against. + */ + boolean isForAccount(@Nullable final String account); + + interface DictionaryInitializationListener { + void onUpdateMainDictionaryAvailability(boolean isMainDictionaryAvailable); + } + + /** + * Called every time {@link LatinIME} starts on a new text field. + * Dot not affect {@link AndroidSpellCheckerService}. + * + * WARNING: The service methods that call start/finish are very spammy. + */ + void onStartInput(); + + /** + * Called every time the {@link LatinIME} finishes with the current text field. + * May be followed by {@link #onStartInput} again in another text field, + * or it may be done for a while. + * Dot not affect {@link AndroidSpellCheckerService}. + * + * WARNING: The service methods that call start/finish are very spammy. + */ + void onFinishInput(Context context); + + boolean isActive(); + + Locale getLocale(); + + boolean usesContacts(); + + String getAccount(); + + void resetDictionaries( + final Context context, + final Locale newLocale, + final boolean useContactsDict, + final boolean usePersonalizedDicts, + final boolean forceReloadMainDictionary, + @Nullable final String account, + final String dictNamePrefix, + @Nullable final DictionaryInitializationListener listener); + + @UsedForTesting + void resetDictionariesForTesting( + final Context context, + final Locale locale, + final ArrayList<String> dictionaryTypes, + final HashMap<String, File> dictionaryFiles, + final Map<String, Map<String, String>> additionalDictAttributes, + @Nullable final String account); + + void closeDictionaries(); + + @UsedForTesting + ExpandableBinaryDictionary getSubDictForTesting(final String dictName); + + // The main dictionaries are loaded asynchronously. Don't cache the return value + // of these methods. + boolean hasAtLeastOneInitializedMainDictionary(); + + boolean hasAtLeastOneUninitializedMainDictionary(); + + void waitForLoadingMainDictionaries(final long timeout, final TimeUnit unit) + throws InterruptedException; + + @UsedForTesting + void waitForLoadingDictionariesForTesting(final long timeout, final TimeUnit unit) + throws InterruptedException; + + void addToUserHistory(final String suggestion, final boolean wasAutoCapitalized, + @Nonnull final NgramContext ngramContext, final long timeStampInSeconds, + final boolean blockPotentiallyOffensive); + + void unlearnFromUserHistory(final String word, + @Nonnull final NgramContext ngramContext, final long timeStampInSeconds, + final int eventType); + + // TODO: Revise the way to fusion suggestion results. + @Nonnull SuggestionResults getSuggestionResults(final ComposedData composedData, + final NgramContext ngramContext, @Nonnull final Keyboard keyboard, + final SettingsValuesForSuggestion settingsValuesForSuggestion, final int sessionId, + final int inputStyle); + + boolean isValidSpellingWord(final String word); + + boolean isValidSuggestionWord(final String word); + + boolean clearUserHistoryDictionary(final Context context); + + String dump(final Context context); + + void dumpDictionaryForDebug(final String dictName); + + @Nonnull List<DictionaryStats> getDictionaryStats(final Context context); +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorImpl.java b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorImpl.java new file mode 100644 index 000000000..63c2cea4e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorImpl.java @@ -0,0 +1,736 @@ +/* +7 * 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 org.kelar.inputmethod.latin; + +import android.Manifest; +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; +import android.util.LruCache; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.NgramContext.WordInfo; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.personalization.UserHistoryDictionary; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.ExecutorUtils; +import org.kelar.inputmethod.latin.utils.SuggestionResults; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Facilitates interaction with different kinds of dictionaries. Provides APIs + * to instantiate and select the correct dictionaries (based on language or account), + * update entries and fetch suggestions. + * + * Currently AndroidSpellCheckerService and LatinIME both use DictionaryFacilitator as + * a client for interacting with dictionaries. + */ +public class DictionaryFacilitatorImpl implements DictionaryFacilitator { + // TODO: Consolidate dictionaries in native code. + public static final String TAG = DictionaryFacilitatorImpl.class.getSimpleName(); + + // HACK: This threshold is being used when adding a capitalized entry in the User History + // dictionary. + private static final int CAPITALIZED_FORM_MAX_PROBABILITY_FOR_INSERT = 140; + + private DictionaryGroup mDictionaryGroup = new DictionaryGroup(); + private volatile CountDownLatch mLatchForWaitingLoadingMainDictionaries = new CountDownLatch(0); + // To synchronize assigning mDictionaryGroup to ensure closing dictionaries. + private final Object mLock = new Object(); + + public static final Map<String, Class<? extends ExpandableBinaryDictionary>> + DICT_TYPE_TO_CLASS = new HashMap<>(); + + static { + DICT_TYPE_TO_CLASS.put(Dictionary.TYPE_USER_HISTORY, UserHistoryDictionary.class); + DICT_TYPE_TO_CLASS.put(Dictionary.TYPE_USER, UserBinaryDictionary.class); + DICT_TYPE_TO_CLASS.put(Dictionary.TYPE_CONTACTS, ContactsBinaryDictionary.class); + } + + private static final String DICT_FACTORY_METHOD_NAME = "getDictionary"; + private static final Class<?>[] DICT_FACTORY_METHOD_ARG_TYPES = + new Class[] { Context.class, Locale.class, File.class, String.class, String.class }; + + private LruCache<String, Boolean> mValidSpellingWordReadCache; + private LruCache<String, Boolean> mValidSpellingWordWriteCache; + + @Override + public void setValidSpellingWordReadCache(final LruCache<String, Boolean> cache) { + mValidSpellingWordReadCache = cache; + } + + @Override + public void setValidSpellingWordWriteCache(final LruCache<String, Boolean> cache) { + mValidSpellingWordWriteCache = cache; + } + + @Override + public boolean isForLocale(final Locale locale) { + return locale != null && locale.equals(mDictionaryGroup.mLocale); + } + + /** + * Returns whether this facilitator is exactly for this account. + * + * @param account the account to test against. + */ + public boolean isForAccount(@Nullable final String account) { + return TextUtils.equals(mDictionaryGroup.mAccount, account); + } + + /** + * A group of dictionaries that work together for a single language. + */ + private static class DictionaryGroup { + // TODO: Add null analysis annotations. + // TODO: Run evaluation to determine a reasonable value for these constants. The current + // values are ad-hoc and chosen without any particular care or methodology. + public static final float WEIGHT_FOR_MOST_PROBABLE_LANGUAGE = 1.0f; + public static final float WEIGHT_FOR_GESTURING_IN_NOT_MOST_PROBABLE_LANGUAGE = 0.95f; + public static final float WEIGHT_FOR_TYPING_IN_NOT_MOST_PROBABLE_LANGUAGE = 0.6f; + + /** + * The locale associated with the dictionary group. + */ + @Nullable public final Locale mLocale; + + /** + * The user account associated with the dictionary group. + */ + @Nullable public final String mAccount; + + @Nullable private Dictionary mMainDict; + // Confidence that the most probable language is actually the language the user is + // typing in. For now, this is simply the number of times a word from this language + // has been committed in a row. + private int mConfidence = 0; + + public float mWeightForTypingInLocale = WEIGHT_FOR_MOST_PROBABLE_LANGUAGE; + public float mWeightForGesturingInLocale = WEIGHT_FOR_MOST_PROBABLE_LANGUAGE; + public final ConcurrentHashMap<String, ExpandableBinaryDictionary> mSubDictMap = + new ConcurrentHashMap<>(); + + public DictionaryGroup() { + this(null /* locale */, null /* mainDict */, null /* account */, + Collections.<String, ExpandableBinaryDictionary>emptyMap() /* subDicts */); + } + + public DictionaryGroup(@Nullable final Locale locale, + @Nullable final Dictionary mainDict, + @Nullable final String account, + final Map<String, ExpandableBinaryDictionary> subDicts) { + mLocale = locale; + mAccount = account; + // The main dictionary can be asynchronously loaded. + setMainDict(mainDict); + for (final Map.Entry<String, ExpandableBinaryDictionary> entry : subDicts.entrySet()) { + setSubDict(entry.getKey(), entry.getValue()); + } + } + + private void setSubDict(final String dictType, final ExpandableBinaryDictionary dict) { + if (dict != null) { + mSubDictMap.put(dictType, dict); + } + } + + public void setMainDict(final Dictionary mainDict) { + // Close old dictionary if exists. Main dictionary can be assigned multiple times. + final Dictionary oldDict = mMainDict; + mMainDict = mainDict; + if (oldDict != null && mainDict != oldDict) { + oldDict.close(); + } + } + + public Dictionary getDict(final String dictType) { + if (Dictionary.TYPE_MAIN.equals(dictType)) { + return mMainDict; + } + return getSubDict(dictType); + } + + public ExpandableBinaryDictionary getSubDict(final String dictType) { + return mSubDictMap.get(dictType); + } + + public boolean hasDict(final String dictType, @Nullable final String account) { + if (Dictionary.TYPE_MAIN.equals(dictType)) { + return mMainDict != null; + } + if (Dictionary.TYPE_USER_HISTORY.equals(dictType) && + !TextUtils.equals(account, mAccount)) { + // If the dictionary type is user history, & if the account doesn't match, + // return immediately. If the account matches, continue looking it up in the + // sub dictionary map. + return false; + } + return mSubDictMap.containsKey(dictType); + } + + public void closeDict(final String dictType) { + final Dictionary dict; + if (Dictionary.TYPE_MAIN.equals(dictType)) { + dict = mMainDict; + } else { + dict = mSubDictMap.remove(dictType); + } + if (dict != null) { + dict.close(); + } + } + } + + public DictionaryFacilitatorImpl() { + } + + @Override + public void onStartInput() { + } + + @Override + public void onFinishInput(Context context) { + } + + @Override + public boolean isActive() { + return mDictionaryGroup.mLocale != null; + } + + @Override + public Locale getLocale() { + return mDictionaryGroup.mLocale; + } + + @Override + public boolean usesContacts() { + return mDictionaryGroup.getSubDict(Dictionary.TYPE_CONTACTS) != null; + } + + @Override + public String getAccount() { + return null; + } + + @Nullable + private static ExpandableBinaryDictionary getSubDict(final String dictType, + final Context context, final Locale locale, final File dictFile, + final String dictNamePrefix, @Nullable final String account) { + final Class<? extends ExpandableBinaryDictionary> dictClass = + DICT_TYPE_TO_CLASS.get(dictType); + if (dictClass == null) { + return null; + } + try { + final Method factoryMethod = dictClass.getMethod(DICT_FACTORY_METHOD_NAME, + DICT_FACTORY_METHOD_ARG_TYPES); + final Object dict = factoryMethod.invoke(null /* obj */, + new Object[] { context, locale, dictFile, dictNamePrefix, account }); + return (ExpandableBinaryDictionary) dict; + } catch (final NoSuchMethodException | SecurityException | IllegalAccessException + | IllegalArgumentException | InvocationTargetException e) { + Log.e(TAG, "Cannot create dictionary: " + dictType, e); + return null; + } + } + + @Nullable + static DictionaryGroup findDictionaryGroupWithLocale(final DictionaryGroup dictionaryGroup, + final Locale locale) { + return locale.equals(dictionaryGroup.mLocale) ? dictionaryGroup : null; + } + + @Override + public void resetDictionaries( + final Context context, + final Locale newLocale, + final boolean useContactsDict, + final boolean usePersonalizedDicts, + final boolean forceReloadMainDictionary, + @Nullable final String account, + final String dictNamePrefix, + @Nullable final DictionaryInitializationListener listener) { + final HashMap<Locale, ArrayList<String>> existingDictionariesToCleanup = new HashMap<>(); + // TODO: Make subDictTypesToUse configurable by resource or a static final list. + final HashSet<String> subDictTypesToUse = new HashSet<>(); + subDictTypesToUse.add(Dictionary.TYPE_USER); + + // Do not use contacts dictionary if we do not have permissions to read contacts. + final boolean contactsPermissionGranted = PermissionsUtil.checkAllPermissionsGranted( + context, Manifest.permission.READ_CONTACTS); + if (useContactsDict && contactsPermissionGranted) { + subDictTypesToUse.add(Dictionary.TYPE_CONTACTS); + } + if (usePersonalizedDicts) { + subDictTypesToUse.add(Dictionary.TYPE_USER_HISTORY); + } + + // Gather all dictionaries. We'll remove them from the list to clean up later. + final ArrayList<String> dictTypeForLocale = new ArrayList<>(); + existingDictionariesToCleanup.put(newLocale, dictTypeForLocale); + final DictionaryGroup currentDictionaryGroupForLocale = + findDictionaryGroupWithLocale(mDictionaryGroup, newLocale); + if (currentDictionaryGroupForLocale != null) { + for (final String dictType : DYNAMIC_DICTIONARY_TYPES) { + if (currentDictionaryGroupForLocale.hasDict(dictType, account)) { + dictTypeForLocale.add(dictType); + } + } + if (currentDictionaryGroupForLocale.hasDict(Dictionary.TYPE_MAIN, account)) { + dictTypeForLocale.add(Dictionary.TYPE_MAIN); + } + } + + final DictionaryGroup dictionaryGroupForLocale = + findDictionaryGroupWithLocale(mDictionaryGroup, newLocale); + final ArrayList<String> dictTypesToCleanupForLocale = + existingDictionariesToCleanup.get(newLocale); + final boolean noExistingDictsForThisLocale = (null == dictionaryGroupForLocale); + + final Dictionary mainDict; + if (forceReloadMainDictionary || noExistingDictsForThisLocale + || !dictionaryGroupForLocale.hasDict(Dictionary.TYPE_MAIN, account)) { + mainDict = null; + } else { + mainDict = dictionaryGroupForLocale.getDict(Dictionary.TYPE_MAIN); + dictTypesToCleanupForLocale.remove(Dictionary.TYPE_MAIN); + } + + final Map<String, ExpandableBinaryDictionary> subDicts = new HashMap<>(); + for (final String subDictType : subDictTypesToUse) { + final ExpandableBinaryDictionary subDict; + if (noExistingDictsForThisLocale + || !dictionaryGroupForLocale.hasDict(subDictType, account)) { + // Create a new dictionary. + subDict = getSubDict(subDictType, context, newLocale, null /* dictFile */, + dictNamePrefix, account); + } else { + // Reuse the existing dictionary, and don't close it at the end + subDict = dictionaryGroupForLocale.getSubDict(subDictType); + dictTypesToCleanupForLocale.remove(subDictType); + } + subDicts.put(subDictType, subDict); + } + DictionaryGroup newDictionaryGroup = + new DictionaryGroup(newLocale, mainDict, account, subDicts); + + // Replace Dictionaries. + final DictionaryGroup oldDictionaryGroup; + synchronized (mLock) { + oldDictionaryGroup = mDictionaryGroup; + mDictionaryGroup = newDictionaryGroup; + if (hasAtLeastOneUninitializedMainDictionary()) { + asyncReloadUninitializedMainDictionaries(context, newLocale, listener); + } + } + if (listener != null) { + listener.onUpdateMainDictionaryAvailability(hasAtLeastOneInitializedMainDictionary()); + } + + // Clean up old dictionaries. + for (final Locale localeToCleanUp : existingDictionariesToCleanup.keySet()) { + final ArrayList<String> dictTypesToCleanUp = + existingDictionariesToCleanup.get(localeToCleanUp); + final DictionaryGroup dictionarySetToCleanup = + findDictionaryGroupWithLocale(oldDictionaryGroup, localeToCleanUp); + for (final String dictType : dictTypesToCleanUp) { + dictionarySetToCleanup.closeDict(dictType); + } + } + + if (mValidSpellingWordWriteCache != null) { + mValidSpellingWordWriteCache.evictAll(); + } + } + + private void asyncReloadUninitializedMainDictionaries(final Context context, + final Locale locale, final DictionaryInitializationListener listener) { + final CountDownLatch latchForWaitingLoadingMainDictionary = new CountDownLatch(1); + mLatchForWaitingLoadingMainDictionaries = latchForWaitingLoadingMainDictionary; + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(new Runnable() { + @Override + public void run() { + doReloadUninitializedMainDictionaries( + context, locale, listener, latchForWaitingLoadingMainDictionary); + } + }); + } + + void doReloadUninitializedMainDictionaries(final Context context, final Locale locale, + final DictionaryInitializationListener listener, + final CountDownLatch latchForWaitingLoadingMainDictionary) { + final DictionaryGroup dictionaryGroup = + findDictionaryGroupWithLocale(mDictionaryGroup, locale); + if (null == dictionaryGroup) { + // This should never happen, but better safe than crashy + Log.w(TAG, "Expected a dictionary group for " + locale + " but none found"); + return; + } + final Dictionary mainDict = + DictionaryFactory.createMainDictionaryFromManager(context, locale); + synchronized (mLock) { + if (locale.equals(dictionaryGroup.mLocale)) { + dictionaryGroup.setMainDict(mainDict); + } else { + // Dictionary facilitator has been reset for another locale. + mainDict.close(); + } + } + if (listener != null) { + listener.onUpdateMainDictionaryAvailability(hasAtLeastOneInitializedMainDictionary()); + } + latchForWaitingLoadingMainDictionary.countDown(); + } + + @UsedForTesting + public void resetDictionariesForTesting(final Context context, final Locale locale, + final ArrayList<String> dictionaryTypes, final HashMap<String, File> dictionaryFiles, + final Map<String, Map<String, String>> additionalDictAttributes, + @Nullable final String account) { + Dictionary mainDictionary = null; + final Map<String, ExpandableBinaryDictionary> subDicts = new HashMap<>(); + + for (final String dictType : dictionaryTypes) { + if (dictType.equals(Dictionary.TYPE_MAIN)) { + mainDictionary = DictionaryFactory.createMainDictionaryFromManager(context, + locale); + } else { + final File dictFile = dictionaryFiles.get(dictType); + final ExpandableBinaryDictionary dict = getSubDict( + dictType, context, locale, dictFile, "" /* dictNamePrefix */, account); + if (additionalDictAttributes.containsKey(dictType)) { + dict.clearAndFlushDictionaryWithAdditionalAttributes( + additionalDictAttributes.get(dictType)); + } + if (dict == null) { + throw new RuntimeException("Unknown dictionary type: " + dictType); + } + dict.reloadDictionaryIfRequired(); + dict.waitAllTasksForTests(); + subDicts.put(dictType, dict); + } + } + mDictionaryGroup = new DictionaryGroup(locale, mainDictionary, account, subDicts); + } + + public void closeDictionaries() { + final DictionaryGroup dictionaryGroupToClose; + synchronized (mLock) { + dictionaryGroupToClose = mDictionaryGroup; + mDictionaryGroup = new DictionaryGroup(); + } + for (final String dictType : ALL_DICTIONARY_TYPES) { + dictionaryGroupToClose.closeDict(dictType); + } + } + + @UsedForTesting + public ExpandableBinaryDictionary getSubDictForTesting(final String dictName) { + return mDictionaryGroup.getSubDict(dictName); + } + + // The main dictionaries are loaded asynchronously. Don't cache the return value + // of these methods. + public boolean hasAtLeastOneInitializedMainDictionary() { + final Dictionary mainDict = mDictionaryGroup.getDict(Dictionary.TYPE_MAIN); + if (mainDict != null && mainDict.isInitialized()) { + return true; + } + return false; + } + + public boolean hasAtLeastOneUninitializedMainDictionary() { + final Dictionary mainDict = mDictionaryGroup.getDict(Dictionary.TYPE_MAIN); + if (mainDict == null || !mainDict.isInitialized()) { + return true; + } + return false; + } + + public void waitForLoadingMainDictionaries(final long timeout, final TimeUnit unit) + throws InterruptedException { + mLatchForWaitingLoadingMainDictionaries.await(timeout, unit); + } + + @UsedForTesting + public void waitForLoadingDictionariesForTesting(final long timeout, final TimeUnit unit) + throws InterruptedException { + waitForLoadingMainDictionaries(timeout, unit); + for (final ExpandableBinaryDictionary dict : mDictionaryGroup.mSubDictMap.values()) { + dict.waitAllTasksForTests(); + } + } + + public void addToUserHistory(final String suggestion, final boolean wasAutoCapitalized, + @Nonnull final NgramContext ngramContext, final long timeStampInSeconds, + final boolean blockPotentiallyOffensive) { + // Update the spelling cache before learning. Words that are not yet added to user history + // and appear in no other language model are not considered valid. + putWordIntoValidSpellingWordCache("addToUserHistory", suggestion); + + final String[] words = suggestion.split(Constants.WORD_SEPARATOR); + NgramContext ngramContextForCurrentWord = ngramContext; + for (int i = 0; i < words.length; i++) { + final String currentWord = words[i]; + final boolean wasCurrentWordAutoCapitalized = (i == 0) ? wasAutoCapitalized : false; + addWordToUserHistory(mDictionaryGroup, ngramContextForCurrentWord, currentWord, + wasCurrentWordAutoCapitalized, (int) timeStampInSeconds, + blockPotentiallyOffensive); + ngramContextForCurrentWord = + ngramContextForCurrentWord.getNextNgramContext(new WordInfo(currentWord)); + } + } + + private void putWordIntoValidSpellingWordCache( + @Nonnull final String caller, + @Nonnull final String originalWord) { + if (mValidSpellingWordWriteCache == null) { + return; + } + + final String lowerCaseWord = originalWord.toLowerCase(getLocale()); + final boolean lowerCaseValid = isValidSpellingWord(lowerCaseWord); + mValidSpellingWordWriteCache.put(lowerCaseWord, lowerCaseValid); + + final String capitalWord = + StringUtils.capitalizeFirstAndDowncaseRest(originalWord, getLocale()); + final boolean capitalValid; + if (lowerCaseValid) { + // The lower case form of the word is valid, so the upper case must be valid. + capitalValid = true; + } else { + capitalValid = isValidSpellingWord(capitalWord); + } + mValidSpellingWordWriteCache.put(capitalWord, capitalValid); + } + + private void addWordToUserHistory(final DictionaryGroup dictionaryGroup, + final NgramContext ngramContext, final String word, final boolean wasAutoCapitalized, + final int timeStampInSeconds, final boolean blockPotentiallyOffensive) { + final ExpandableBinaryDictionary userHistoryDictionary = + dictionaryGroup.getSubDict(Dictionary.TYPE_USER_HISTORY); + if (userHistoryDictionary == null || !isForLocale(userHistoryDictionary.mLocale)) { + return; + } + final int maxFreq = getFrequency(word); + if (maxFreq == 0 && blockPotentiallyOffensive) { + return; + } + final String lowerCasedWord = word.toLowerCase(dictionaryGroup.mLocale); + final String secondWord; + if (wasAutoCapitalized) { + if (isValidSuggestionWord(word) && !isValidSuggestionWord(lowerCasedWord)) { + // If the word was auto-capitalized and exists only as a capitalized word in the + // dictionary, then we must not downcase it before registering it. For example, + // the name of the contacts in start-of-sentence position would come here with the + // wasAutoCapitalized flag: if we downcase it, we'd register a lower-case version + // of that contact's name which would end up popping in suggestions. + secondWord = word; + } else { + // If however the word is not in the dictionary, or exists as a lower-case word + // only, then we consider that was a lower-case word that had been auto-capitalized. + secondWord = lowerCasedWord; + } + } else { + // HACK: We'd like to avoid adding the capitalized form of common words to the User + // History dictionary in order to avoid suggesting them until the dictionary + // consolidation is done. + // TODO: Remove this hack when ready. + final int lowerCaseFreqInMainDict = dictionaryGroup.hasDict(Dictionary.TYPE_MAIN, + null /* account */) ? + dictionaryGroup.getDict(Dictionary.TYPE_MAIN).getFrequency(lowerCasedWord) : + Dictionary.NOT_A_PROBABILITY; + if (maxFreq < lowerCaseFreqInMainDict + && lowerCaseFreqInMainDict >= CAPITALIZED_FORM_MAX_PROBABILITY_FOR_INSERT) { + // Use lower cased word as the word can be a distracter of the popular word. + secondWord = lowerCasedWord; + } else { + secondWord = word; + } + } + // We demote unrecognized words (frequency < 0, below) by specifying them as "invalid". + // We don't add words with 0-frequency (assuming they would be profanity etc.). + final boolean isValid = maxFreq > 0; + UserHistoryDictionary.addToDictionary(userHistoryDictionary, ngramContext, secondWord, + isValid, timeStampInSeconds); + } + + private void removeWord(final String dictName, final String word) { + final ExpandableBinaryDictionary dictionary = mDictionaryGroup.getSubDict(dictName); + if (dictionary != null) { + dictionary.removeUnigramEntryDynamically(word); + } + } + + @Override + public void unlearnFromUserHistory(final String word, + @Nonnull final NgramContext ngramContext, final long timeStampInSeconds, + final int eventType) { + // TODO: Decide whether or not to remove the word on EVENT_BACKSPACE. + if (eventType != Constants.EVENT_BACKSPACE) { + removeWord(Dictionary.TYPE_USER_HISTORY, word); + } + + // Update the spelling cache after unlearning. Words that are removed from user history + // and appear in no other language model are not considered valid. + putWordIntoValidSpellingWordCache("unlearnFromUserHistory", word.toLowerCase()); + } + + // TODO: Revise the way to fusion suggestion results. + @Override + @Nonnull public SuggestionResults getSuggestionResults(ComposedData composedData, + NgramContext ngramContext, @Nonnull final Keyboard keyboard, + SettingsValuesForSuggestion settingsValuesForSuggestion, int sessionId, + int inputStyle) { + long proximityInfoHandle = keyboard.getProximityInfo().getNativeProximityInfo(); + final SuggestionResults suggestionResults = new SuggestionResults( + SuggestedWords.MAX_SUGGESTIONS, ngramContext.isBeginningOfSentenceContext(), + false /* firstSuggestionExceedsConfidenceThreshold */); + final float[] weightOfLangModelVsSpatialModel = + new float[] { Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL }; + for (final String dictType : ALL_DICTIONARY_TYPES) { + final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + if (null == dictionary) continue; + final float weightForLocale = composedData.mIsBatchMode + ? mDictionaryGroup.mWeightForGesturingInLocale + : mDictionaryGroup.mWeightForTypingInLocale; + final ArrayList<SuggestedWordInfo> dictionarySuggestions = + dictionary.getSuggestions(composedData, ngramContext, + proximityInfoHandle, settingsValuesForSuggestion, sessionId, + weightForLocale, weightOfLangModelVsSpatialModel); + if (null == dictionarySuggestions) continue; + suggestionResults.addAll(dictionarySuggestions); + if (null != suggestionResults.mRawSuggestions) { + suggestionResults.mRawSuggestions.addAll(dictionarySuggestions); + } + } + return suggestionResults; + } + + public boolean isValidSpellingWord(final String word) { + if (mValidSpellingWordReadCache != null) { + final Boolean cachedValue = mValidSpellingWordReadCache.get(word); + if (cachedValue != null) { + return cachedValue; + } + } + + return isValidWord(word, ALL_DICTIONARY_TYPES); + } + + public boolean isValidSuggestionWord(final String word) { + return isValidWord(word, ALL_DICTIONARY_TYPES); + } + + private boolean isValidWord(final String word, final String[] dictionariesToCheck) { + if (TextUtils.isEmpty(word)) { + return false; + } + if (mDictionaryGroup.mLocale == null) { + return false; + } + for (final String dictType : dictionariesToCheck) { + final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + // Ideally the passed map would come out of a {@link java.util.concurrent.Future} and + // would be immutable once it's finished initializing, but concretely a null test is + // probably good enough for the time being. + if (null == dictionary) continue; + if (dictionary.isValidWord(word)) { + return true; + } + } + return false; + } + + private int getFrequency(final String word) { + if (TextUtils.isEmpty(word)) { + return Dictionary.NOT_A_PROBABILITY; + } + int maxFreq = Dictionary.NOT_A_PROBABILITY; + for (final String dictType : ALL_DICTIONARY_TYPES) { + final Dictionary dictionary = mDictionaryGroup.getDict(dictType); + if (dictionary == null) continue; + final int tempFreq = dictionary.getFrequency(word); + if (tempFreq >= maxFreq) { + maxFreq = tempFreq; + } + } + return maxFreq; + } + + private boolean clearSubDictionary(final String dictName) { + final ExpandableBinaryDictionary dictionary = mDictionaryGroup.getSubDict(dictName); + if (dictionary == null) { + return false; + } + dictionary.clear(); + return true; + } + + @Override + public boolean clearUserHistoryDictionary(final Context context) { + return clearSubDictionary(Dictionary.TYPE_USER_HISTORY); + } + + @Override + public void dumpDictionaryForDebug(final String dictName) { + final ExpandableBinaryDictionary dictToDump = mDictionaryGroup.getSubDict(dictName); + if (dictToDump == null) { + Log.e(TAG, "Cannot dump " + dictName + ". " + + "The dictionary is not being used for suggestion or cannot be dumped."); + return; + } + dictToDump.dumpAllWordsForDebug(); + } + + @Override + @Nonnull public List<DictionaryStats> getDictionaryStats(final Context context) { + final ArrayList<DictionaryStats> statsOfEnabledSubDicts = new ArrayList<>(); + for (final String dictType : DYNAMIC_DICTIONARY_TYPES) { + final ExpandableBinaryDictionary dictionary = mDictionaryGroup.getSubDict(dictType); + if (dictionary == null) continue; + statsOfEnabledSubDicts.add(dictionary.getDictionaryStats()); + } + return statsOfEnabledSubDicts; + } + + @Override + public String dump(final Context context) { + return ""; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorLruCache.java b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorLruCache.java new file mode 100644 index 000000000..b20fad30c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorLruCache.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import android.content.Context; +import android.util.Log; + +/** + * Cache for dictionary facilitators of multiple locales. + * This class automatically creates and releases up to 3 facilitator instances using LRU policy. + */ +public class DictionaryFacilitatorLruCache { + private static final String TAG = "DictionaryFacilitatorLruCache"; + private static final int WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS = 1000; + private static final int MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT = 5; + + private final Context mContext; + private final String mDictionaryNamePrefix; + private final Object mLock = new Object(); + private final DictionaryFacilitator mDictionaryFacilitator; + private boolean mUseContactsDictionary; + private Locale mLocale; + + public DictionaryFacilitatorLruCache(final Context context, final String dictionaryNamePrefix) { + mContext = context; + mDictionaryNamePrefix = dictionaryNamePrefix; + mDictionaryFacilitator = DictionaryFacilitatorProvider.getDictionaryFacilitator( + true /* isNeededForSpellChecking */); + } + + private static void waitForLoadingMainDictionary( + final DictionaryFacilitator dictionaryFacilitator) { + for (int i = 0; i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT; i++) { + try { + dictionaryFacilitator.waitForLoadingMainDictionaries( + WAIT_FOR_LOADING_MAIN_DICT_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + return; + } catch (final InterruptedException e) { + Log.i(TAG, "Interrupted during waiting for loading main dictionary.", e); + if (i < MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT - 1) { + Log.i(TAG, "Retry", e); + } else { + Log.w(TAG, "Give up retrying. Retried " + + MAX_RETRY_COUNT_FOR_WAITING_FOR_LOADING_DICT + " times.", e); + } + } + } + } + + private void resetDictionariesForLocaleLocked() { + // Nothing to do if the locale is null. This would be the case before any get() calls. + if (mLocale != null) { + // Note: Given that personalized dictionaries are not used here; we can pass null account. + mDictionaryFacilitator.resetDictionaries(mContext, mLocale, + mUseContactsDictionary, false /* usePersonalizedDicts */, + false /* forceReloadMainDictionary */, null /* account */, + mDictionaryNamePrefix, null /* listener */); + } + } + + public void setUseContactsDictionary(final boolean useContactsDictionary) { + synchronized (mLock) { + if (mUseContactsDictionary == useContactsDictionary) { + // The value has not been changed. + return; + } + mUseContactsDictionary = useContactsDictionary; + resetDictionariesForLocaleLocked(); + waitForLoadingMainDictionary(mDictionaryFacilitator); + } + } + + public DictionaryFacilitator get(final Locale locale) { + synchronized (mLock) { + if (!mDictionaryFacilitator.isForLocale(locale)) { + mLocale = locale; + resetDictionariesForLocaleLocked(); + } + waitForLoadingMainDictionary(mDictionaryFacilitator); + return mDictionaryFacilitator; + } + } + + public void closeDictionaries() { + synchronized (mLock) { + mDictionaryFacilitator.closeDictionaries(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorProvider.java b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorProvider.java new file mode 100644 index 000000000..1a932c77a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorProvider.java @@ -0,0 +1,26 @@ +/* + * 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 org.kelar.inputmethod.latin; + +/** + * Factory for instantiating DictionaryFacilitator objects. + */ +public class DictionaryFacilitatorProvider { + public static DictionaryFacilitator getDictionaryFacilitator(boolean isNeededForSpellChecking) { + return new DictionaryFacilitatorImpl(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryFactory.java b/java/src/org/kelar/inputmethod/latin/DictionaryFactory.java new file mode 100644 index 000000000..cb5378aef --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryFactory.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.util.Log; + +import org.kelar.inputmethod.latin.utils.DictionaryInfoUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.Locale; + +/** + * Factory for dictionary instances. + */ +public final class DictionaryFactory { + private static final String TAG = DictionaryFactory.class.getSimpleName(); + + /** + * Initializes a main dictionary collection from a dictionary pack, with explicit flags. + * + * This searches for a content provider providing a dictionary pack for the specified + * locale. If none is found, it falls back to the built-in dictionary - if any. + * @param context application context for reading resources + * @param locale the locale for which to create the dictionary + * @return an initialized instance of DictionaryCollection + */ + public static DictionaryCollection createMainDictionaryFromManager(final Context context, + final Locale locale) { + if (null == locale) { + Log.e(TAG, "No locale defined for dictionary"); + return new DictionaryCollection(Dictionary.TYPE_MAIN, locale, + createReadOnlyBinaryDictionary(context, locale)); + } + + final LinkedList<Dictionary> dictList = new LinkedList<>(); + final ArrayList<AssetFileAddress> assetFileList = + BinaryDictionaryGetter.getDictionaryFiles(locale, context, true); + if (null != assetFileList) { + for (final AssetFileAddress f : assetFileList) { + final ReadOnlyBinaryDictionary readOnlyBinaryDictionary = + new ReadOnlyBinaryDictionary(f.mFilename, f.mOffset, f.mLength, + false /* useFullEditDistance */, locale, Dictionary.TYPE_MAIN); + if (readOnlyBinaryDictionary.isValidDictionary()) { + dictList.add(readOnlyBinaryDictionary); + } else { + readOnlyBinaryDictionary.close(); + // Prevent this dictionary to do any further harm. + killDictionary(context, f); + } + } + } + + // If the list is empty, that means we should not use any dictionary (for example, the user + // explicitly disabled the main dictionary), so the following is okay. dictList is never + // null, but if for some reason it is, DictionaryCollection handles it gracefully. + return new DictionaryCollection(Dictionary.TYPE_MAIN, locale, dictList); + } + + /** + * Kills a dictionary so that it is never used again, if possible. + * @param context The context to contact the dictionary provider, if possible. + * @param f A file address to the dictionary to kill. + */ + public static void killDictionary(final Context context, final AssetFileAddress f) { + if (f.pointsToPhysicalFile()) { + f.deleteUnderlyingFile(); + // Warn the dictionary provider if the dictionary came from there. + final ContentProviderClient providerClient; + try { + providerClient = context.getContentResolver().acquireContentProviderClient( + BinaryDictionaryFileDumper.getProviderUriBuilder("").build()); + } catch (final SecurityException e) { + Log.e(TAG, "No permission to communicate with the dictionary provider", e); + return; + } + if (null == providerClient) { + Log.e(TAG, "Can't establish communication with the dictionary provider"); + return; + } + final String wordlistId = + DictionaryInfoUtils.getWordListIdFromFileName(new File(f.mFilename).getName()); + // TODO: this is a reasonable last resort, but it is suboptimal. + // The following will remove the entry for this dictionary with the dictionary + // provider. When the metadata is downloaded again, we will try downloading it + // again. + // However, in the practice that will mean the user will find themselves without + // the new dictionary. That's fine for languages where it's included in the APK, + // but for other languages it will leave the user without a dictionary at all until + // the next update, which may be a few days away. + // Ideally, we would trigger a new download right away, and use increasing retry + // delays for this particular id/version combination. + // Then again, this is expected to only ever happen in case of human mistake. If + // the wrong file is on the server, the following is still doing the right thing. + // If it's a file left over from the last version however, it's not great. + BinaryDictionaryFileDumper.reportBrokenFileToDictionaryProvider( + providerClient, + context.getString(R.string.dictionary_pack_client_id), + wordlistId); + } + } + + /** + * Initializes a read-only binary dictionary from a raw resource file + * @param context application context for reading resources + * @param locale the locale to use for the resource + * @return an initialized instance of ReadOnlyBinaryDictionary + */ + private static ReadOnlyBinaryDictionary createReadOnlyBinaryDictionary(final Context context, + final Locale locale) { + AssetFileDescriptor afd = null; + try { + final int resId = DictionaryInfoUtils.getMainDictionaryResourceIdIfAvailableForLocale( + context.getResources(), locale); + if (0 == resId) return null; + afd = context.getResources().openRawResourceFd(resId); + if (afd == null) { + Log.e(TAG, "Found the resource but it is compressed. resId=" + resId); + return null; + } + final String sourceDir = context.getApplicationInfo().sourceDir; + final File packagePath = new File(sourceDir); + // TODO: Come up with a way to handle a directory. + if (!packagePath.isFile()) { + Log.e(TAG, "sourceDir is not a file: " + sourceDir); + return null; + } + return new ReadOnlyBinaryDictionary(sourceDir, afd.getStartOffset(), afd.getLength(), + false /* useFullEditDistance */, locale, Dictionary.TYPE_MAIN); + } catch (android.content.res.Resources.NotFoundException e) { + Log.e(TAG, "Could not find the resource"); + return null; + } finally { + if (null != afd) { + try { + afd.close(); + } catch (java.io.IOException e) { + /* IOException on close ? What am I supposed to do ? */ + } + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java b/java/src/org/kelar/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java new file mode 100644 index 000000000..a756fc0a6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.dictionarypack.DictionaryPackConstants; +import org.kelar.inputmethod.latin.utils.TargetPackageInfoGetterTask; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.net.Uri; +import android.util.Log; + +/** + * Receives broadcasts pertaining to dictionary management and takes the appropriate action. + * + * This object receives three types of broadcasts. + * - Package installed/added. When a dictionary provider application is added or removed, we + * need to query the dictionaries. + * - New dictionary broadcast. The dictionary provider broadcasts new dictionary availability. When + * this happens, we need to re-query the dictionaries. + * - Unknown client. If the dictionary provider is in urgent need of data about some client that + * it does not know, it sends this broadcast. When we receive this, we need to tell the dictionary + * provider about ourselves. This happens when the settings for the dictionary pack are accessed, + * but Latin IME never got a chance to register itself. + */ +public final class DictionaryPackInstallBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = DictionaryPackInstallBroadcastReceiver.class.getSimpleName(); + + final LatinIME mService; + + public DictionaryPackInstallBroadcastReceiver() { + // This empty constructor is necessary for the system to instantiate this receiver. + // This happens when the dictionary pack says it can't find a record for our client, + // which happens when the dictionary pack settings are called before the keyboard + // was ever started once. + Log.i(TAG, "Latin IME dictionary broadcast receiver instantiated from the framework."); + mService = null; + } + + public DictionaryPackInstallBroadcastReceiver(final LatinIME service) { + mService = service; + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + final PackageManager manager = context.getPackageManager(); + + // We need to reread the dictionary if a new dictionary package is installed. + if (action.equals(Intent.ACTION_PACKAGE_ADDED)) { + if (null == mService) { + Log.e(TAG, "Called with intent " + action + " but we don't know the service: this " + + "should never happen"); + return; + } + final Uri packageUri = intent.getData(); + if (null == packageUri) return; // No package name : we can't do anything + final String packageName = packageUri.getSchemeSpecificPart(); + if (null == packageName) return; + // TODO: do this in a more appropriate place + TargetPackageInfoGetterTask.removeCachedPackageInfo(packageName); + final PackageInfo packageInfo; + try { + packageInfo = manager.getPackageInfo(packageName, PackageManager.GET_PROVIDERS); + } catch (android.content.pm.PackageManager.NameNotFoundException e) { + return; // No package info : we can't do anything + } + final ProviderInfo[] providers = packageInfo.providers; + if (null == providers) return; // No providers : it is not a dictionary. + + // Search for some dictionary pack in the just-installed package. If found, reread. + for (ProviderInfo info : providers) { + if (DictionaryPackConstants.AUTHORITY.equals(info.authority)) { + mService.resetSuggestMainDict(); + return; + } + } + // If we come here none of the authorities matched the one we searched for. + // We can exit safely. + return; + } else if (action.equals(Intent.ACTION_PACKAGE_REMOVED) + && !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) { + if (null == mService) { + Log.e(TAG, "Called with intent " + action + " but we don't know the service: this " + + "should never happen"); + return; + } + // When the dictionary package is removed, we need to reread dictionary (to use the + // next-priority one, or stop using a dictionary at all if this was the only one, + // since this is the user request). + // If we are replacing the package, we will receive ADDED right away so no need to + // remove the dictionary at the moment, since we will do it when we receive the + // ADDED broadcast. + + // TODO: Only reload dictionary on REMOVED when the removed package is the one we + // read dictionary from? + mService.resetSuggestMainDict(); + } else if (action.equals(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)) { + if (null == mService) { + Log.e(TAG, "Called with intent " + action + " but we don't know the service: this " + + "should never happen"); + return; + } + mService.resetSuggestMainDict(); + } else if (action.equals(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT)) { + if (null != mService) { + // Careful! This is returning if the service is NOT null. This is because we + // should come here instantiated by the framework in reaction to a broadcast of + // the above action, so we should gave gone through the no-args constructor. + Log.e(TAG, "Called with intent " + action + " but we have a reference to the " + + "service: this should never happen"); + return; + } + // The dictionary provider does not know about some client. We check that it's really + // us that it needs to know about, and if it's the case, we register with the provider. + final String wantedClientId = + intent.getStringExtra(DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA); + final String myClientId = context.getString(R.string.dictionary_pack_client_id); + if (!wantedClientId.equals(myClientId)) return; // Not for us + BinaryDictionaryFileDumper.initializeClientRecordHelper(context, myClientId); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/DictionaryStats.java b/java/src/org/kelar/inputmethod/latin/DictionaryStats.java new file mode 100644 index 000000000..915583a1a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/DictionaryStats.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import java.io.File; +import java.math.BigDecimal; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class DictionaryStats { + public static final int NOT_AN_ENTRY_COUNT = -1; + + public final Locale mLocale; + public final String mDictType; + public final String mDictFileName; + public final long mDictFileSize; + public final int mContentVersion; + public final int mWordCount; + + public DictionaryStats( + @Nonnull final Locale locale, + @Nonnull final String dictType, + @Nullable final String dictFileName, + @Nullable final File dictFile, + final int contentVersion) { + mLocale = locale; + mDictType = dictType; + mDictFileSize = (dictFile == null || !dictFile.exists()) ? 0 : dictFile.length(); + mDictFileName = dictFileName; + mContentVersion = contentVersion; + mWordCount = -1; + } + + public DictionaryStats( + @Nonnull final Locale locale, + @Nonnull final String dictType, + final int wordCount) { + mLocale = locale; + mDictType = dictType; + mDictFileSize = wordCount; + mDictFileName = null; + mContentVersion = 0; + mWordCount = wordCount; + } + + public String getFileSizeString() { + BigDecimal bytes = new BigDecimal(mDictFileSize); + BigDecimal kb = bytes.divide(new BigDecimal(1024), 2, BigDecimal.ROUND_HALF_UP); + if (kb.longValue() == 0) { + return bytes.toString() + " bytes"; + } + BigDecimal mb = kb.divide(new BigDecimal(1024), 2, BigDecimal.ROUND_HALF_UP); + if (mb.longValue() == 0) { + return kb.toString() + " kb"; + } + return mb.toString() + " Mb"; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(mDictType); + if (mDictType.equals(Dictionary.TYPE_MAIN)) { + builder.append(" ("); + builder.append(mContentVersion); + builder.append(")"); + } + builder.append(": "); + if (mWordCount > -1) { + builder.append(mWordCount); + builder.append(" words"); + } else { + builder.append(mDictFileName); + builder.append(" / "); + builder.append(getFileSizeString()); + } + return builder.toString(); + } + + public static String toString(final Iterable<DictionaryStats> stats) { + final StringBuilder builder = new StringBuilder("LM Stats"); + for (DictionaryStats stat : stats) { + builder.append("\n "); + builder.append(stat.toString()); + } + return builder.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/EmojiAltPhysicalKeyDetector.java b/java/src/org/kelar/inputmethod/latin/EmojiAltPhysicalKeyDetector.java new file mode 100644 index 000000000..c8c889e80 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/EmojiAltPhysicalKeyDetector.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2014, 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 org.kelar.inputmethod.latin; + +import android.content.res.Resources; +import android.util.Log; +import android.util.Pair; +import android.view.KeyEvent; + +import org.kelar.inputmethod.keyboard.KeyboardSwitcher; +import org.kelar.inputmethod.latin.settings.Settings; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import javax.annotation.Nonnull; + +/** + * A class for detecting Emoji-Alt physical key. + */ +final class EmojiAltPhysicalKeyDetector { + private static final String TAG = "EmojiAltPhysicalKeyDetector"; + private static final boolean DEBUG = false; + + private List<EmojiHotKeys> mHotKeysList; + + private static class HotKeySet extends HashSet<Pair<Integer, Integer>> { }; + + private abstract class EmojiHotKeys { + private final String mName; + private final HotKeySet mKeySet; + + boolean mCanFire; + int mMetaState; + + public EmojiHotKeys(final String name, HotKeySet keySet) { + mName = name; + mKeySet = keySet; + mCanFire = false; + } + + public void onKeyDown(@Nonnull final KeyEvent keyEvent) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyDown() - " + mName + " - considering " + keyEvent); + } + + final Pair<Integer, Integer> key = + Pair.create(keyEvent.getKeyCode(), keyEvent.getMetaState()); + if (mKeySet.contains(key)) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyDown() - " + mName + " - enabling action"); + } + mCanFire = true; + mMetaState = keyEvent.getMetaState(); + } else if (mCanFire) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyDown() - " + mName + " - disabling action"); + } + mCanFire = false; + } + } + + public void onKeyUp(@Nonnull final KeyEvent keyEvent) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyUp() - " + mName + " - considering " + keyEvent); + } + + final int keyCode = keyEvent.getKeyCode(); + int metaState = keyEvent.getMetaState(); + if (KeyEvent.isModifierKey(keyCode)) { + // Try restoring meta stat in case the released key was a modifier. + // I am sure one can come up with scenarios to break this, but it + // seems to work well in practice. + metaState |= mMetaState; + } + + final Pair<Integer, Integer> key = Pair.create(keyCode, metaState); + if (mKeySet.contains(key)) { + if (mCanFire) { + if (!keyEvent.isCanceled()) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyUp() - " + mName + " - firing action"); + } + action(); + } else { + // This key up event was a part of key combinations and + // should be ignored. + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyUp() - " + mName + " - canceled, ignoring action"); + } + } + mCanFire = false; + } + } + + if (mCanFire) { + if (DEBUG) { + Log.d(TAG, "EmojiHotKeys.onKeyUp() - " + mName + " - disabling action"); + } + mCanFire = false; + } + } + + protected abstract void action(); + } + + public EmojiAltPhysicalKeyDetector(@Nonnull final Resources resources) { + mHotKeysList = new ArrayList<EmojiHotKeys>(); + + final HotKeySet emojiSwitchSet = parseHotKeys( + resources, R.array.keyboard_switcher_emoji); + final EmojiHotKeys emojiHotKeys = new EmojiHotKeys("emoji", emojiSwitchSet) { + @Override + protected void action() { + final KeyboardSwitcher switcher = KeyboardSwitcher.getInstance(); + switcher.onToggleKeyboard(KeyboardSwitcher.KeyboardSwitchState.EMOJI); + } + }; + mHotKeysList.add(emojiHotKeys); + + final HotKeySet symbolsSwitchSet = parseHotKeys( + resources, R.array.keyboard_switcher_symbols_shifted); + final EmojiHotKeys symbolsHotKeys = new EmojiHotKeys("symbols", symbolsSwitchSet) { + @Override + protected void action() { + final KeyboardSwitcher switcher = KeyboardSwitcher.getInstance(); + switcher.onToggleKeyboard(KeyboardSwitcher.KeyboardSwitchState.SYMBOLS_SHIFTED); + } + }; + mHotKeysList.add(symbolsHotKeys); + } + + public void onKeyDown(@Nonnull final KeyEvent keyEvent) { + if (DEBUG) { + Log.d(TAG, "onKeyDown(): " + keyEvent); + } + + if (shouldProcessEvent(keyEvent)) { + for (EmojiHotKeys hotKeys : mHotKeysList) { + hotKeys.onKeyDown(keyEvent); + } + } + } + + public void onKeyUp(@Nonnull final KeyEvent keyEvent) { + if (DEBUG) { + Log.d(TAG, "onKeyUp(): " + keyEvent); + } + + if (shouldProcessEvent(keyEvent)) { + for (EmojiHotKeys hotKeys : mHotKeysList) { + hotKeys.onKeyUp(keyEvent); + } + } + } + + private static boolean shouldProcessEvent(@Nonnull final KeyEvent keyEvent) { + if (!Settings.getInstance().getCurrent().mEnableEmojiAltPhysicalKey) { + // The feature is disabled. + if (DEBUG) { + Log.d(TAG, "shouldProcessEvent(): Disabled"); + } + return false; + } + + return true; + } + + private static HotKeySet parseHotKeys( + @Nonnull final Resources resources, final int resourceId) { + final HotKeySet keySet = new HotKeySet(); + final String name = resources.getResourceEntryName(resourceId); + final String[] values = resources.getStringArray(resourceId); + for (int i = 0; values != null && i < values.length; i++) { + String[] valuePair = values[i].split(","); + if (valuePair.length != 2) { + Log.w(TAG, "Expected 2 integers in " + name + "[" + i + "] : " + values[i]); + } + try { + final Integer keyCode = Integer.parseInt(valuePair[0]); + final Integer metaState = Integer.parseInt(valuePair[1]); + final Pair<Integer, Integer> key = Pair.create( + keyCode, KeyEvent.normalizeMetaState(metaState)); + keySet.add(key); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse " + name + "[" + i + "] : " + values[i], e); + } + } + return keySet; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ExpandableBinaryDictionary.java b/java/src/org/kelar/inputmethod/latin/ExpandableBinaryDictionary.java new file mode 100644 index 000000000..c7b36e71a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ExpandableBinaryDictionary.java @@ -0,0 +1,757 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.common.FileUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.FormatSpec; +import org.kelar.inputmethod.latin.makedict.UnsupportedFormatException; +import org.kelar.inputmethod.latin.makedict.WordProperty; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.AsyncResultHolder; +import org.kelar.inputmethod.latin.utils.CombinedFormatUtils; +import org.kelar.inputmethod.latin.utils.ExecutorUtils; +import org.kelar.inputmethod.latin.utils.WordInputEventForPersonalization; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Abstract base class for an expandable dictionary that can be created and updated dynamically + * during runtime. When updated it automatically generates a new binary dictionary to handle future + * queries in native code. This binary dictionary is written to internal storage. + * + * A class that extends this abstract class must have a static factory method named + * getDictionary(Context context, Locale locale, File dictFile, String dictNamePrefix) + */ +abstract public class ExpandableBinaryDictionary extends Dictionary { + private static final boolean DEBUG = false; + + /** Used for Log actions from this class */ + private static final String TAG = ExpandableBinaryDictionary.class.getSimpleName(); + + /** Whether to print debug output to log */ + private static final boolean DBG_STRESS_TEST = false; + + private static final int TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS = 100; + + /** + * The maximum length of a word in this dictionary. + */ + protected static final int MAX_WORD_LENGTH = + DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH; + + private static final int DICTIONARY_FORMAT_VERSION = FormatSpec.VERSION4; + + private static final WordProperty[] DEFAULT_WORD_PROPERTIES_FOR_SYNC = + new WordProperty[0] /* default */; + + /** The application context. */ + protected final Context mContext; + + /** + * The binary dictionary generated dynamically from the fusion dictionary. This is used to + * answer unigram and bigram queries. + */ + private BinaryDictionary mBinaryDictionary; + + /** + * The name of this dictionary, used as a part of the filename for storing the binary + * dictionary. + */ + private final String mDictName; + + /** Dictionary file */ + private final File mDictFile; + + /** Indicates whether a task for reloading the dictionary has been scheduled. */ + private final AtomicBoolean mIsReloading; + + /** Indicates whether the current dictionary needs to be recreated. */ + private boolean mNeedsToRecreate; + + private final ReentrantReadWriteLock mLock; + + private Map<String, String> mAdditionalAttributeMap = null; + + /* A extension for a binary dictionary file. */ + protected static final String DICT_FILE_EXTENSION = ".dict"; + + /** + * Abstract method for loading initial contents of a given dictionary. + */ + protected abstract void loadInitialContentsLocked(); + + static boolean matchesExpectedBinaryDictFormatVersionForThisType(final int formatVersion) { + return formatVersion == FormatSpec.VERSION4; + } + + private static boolean needsToMigrateDictionary(final int formatVersion) { + // When we bump up the dictionary format version, the old version should be added to here + // for supporting migration. Note that native code has to support reading such formats. + return formatVersion == FormatSpec.VERSION402; + } + + public boolean isValidDictionaryLocked() { + return mBinaryDictionary.isValidDictionary(); + } + + /** + * Creates a new expandable binary dictionary. + * + * @param context The application context of the parent. + * @param dictName The name of the dictionary. Multiple instances with the same + * name is supported. + * @param locale the dictionary locale. + * @param dictType the dictionary type, as a human-readable string + * @param dictFile dictionary file path. if null, use default dictionary path based on + * dictionary type. + */ + public ExpandableBinaryDictionary(final Context context, final String dictName, + final Locale locale, final String dictType, final File dictFile) { + super(dictType, locale); + mDictName = dictName; + mContext = context; + mDictFile = getDictFile(context, dictName, dictFile); + mBinaryDictionary = null; + mIsReloading = new AtomicBoolean(); + mNeedsToRecreate = false; + mLock = new ReentrantReadWriteLock(); + } + + public static File getDictFile(final Context context, final String dictName, + final File dictFile) { + return (dictFile != null) ? dictFile + : new File(context.getFilesDir(), dictName + DICT_FILE_EXTENSION); + } + + public static String getDictName(final String name, final Locale locale, + final File dictFile) { + return dictFile != null ? dictFile.getName() : name + "." + locale.toString(); + } + + private void asyncExecuteTaskWithWriteLock(final Runnable task) { + asyncExecuteTaskWithLock(mLock.writeLock(), task); + } + + private static void asyncExecuteTaskWithLock(final Lock lock, final Runnable task) { + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(new Runnable() { + @Override + public void run() { + lock.lock(); + try { + task.run(); + } finally { + lock.unlock(); + } + } + }); + } + + @Nullable + BinaryDictionary getBinaryDictionary() { + return mBinaryDictionary; + } + + void closeBinaryDictionary() { + if (mBinaryDictionary != null) { + mBinaryDictionary.close(); + mBinaryDictionary = null; + } + } + + /** + * Closes and cleans up the binary dictionary. + */ + @Override + public void close() { + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + closeBinaryDictionary(); + } + }); + } + + protected Map<String, String> getHeaderAttributeMap() { + HashMap<String, String> attributeMap = new HashMap<>(); + if (mAdditionalAttributeMap != null) { + attributeMap.putAll(mAdditionalAttributeMap); + } + attributeMap.put(DictionaryHeader.DICTIONARY_ID_KEY, mDictName); + attributeMap.put(DictionaryHeader.DICTIONARY_LOCALE_KEY, mLocale.toString()); + attributeMap.put(DictionaryHeader.DICTIONARY_VERSION_KEY, + String.valueOf(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()))); + return attributeMap; + } + + private void removeBinaryDictionary() { + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + removeBinaryDictionaryLocked(); + } + }); + } + + void removeBinaryDictionaryLocked() { + closeBinaryDictionary(); + if (mDictFile.exists() && !FileUtils.deleteRecursively(mDictFile)) { + Log.e(TAG, "Can't remove a file: " + mDictFile.getName()); + } + } + + private void openBinaryDictionaryLocked() { + mBinaryDictionary = new BinaryDictionary( + mDictFile.getAbsolutePath(), 0 /* offset */, mDictFile.length(), + true /* useFullEditDistance */, mLocale, mDictType, true /* isUpdatable */); + } + + void createOnMemoryBinaryDictionaryLocked() { + mBinaryDictionary = new BinaryDictionary( + mDictFile.getAbsolutePath(), true /* useFullEditDistance */, mLocale, mDictType, + DICTIONARY_FORMAT_VERSION, getHeaderAttributeMap()); + } + + public void clear() { + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + removeBinaryDictionaryLocked(); + createOnMemoryBinaryDictionaryLocked(); + } + }); + } + + /** + * Check whether GC is needed and run GC if required. + */ + public void runGCIfRequired(final boolean mindsBlockByGC) { + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + if (getBinaryDictionary() == null) { + return; + } + runGCIfRequiredLocked(mindsBlockByGC); + } + }); + } + + protected void runGCIfRequiredLocked(final boolean mindsBlockByGC) { + if (mBinaryDictionary.needsToRunGC(mindsBlockByGC)) { + mBinaryDictionary.flushWithGC(); + } + } + + private void updateDictionaryWithWriteLock(@Nonnull final Runnable updateTask) { + reloadDictionaryIfRequired(); + final Runnable task = new Runnable() { + @Override + public void run() { + if (getBinaryDictionary() == null) { + return; + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + updateTask.run(); + } + }; + asyncExecuteTaskWithWriteLock(task); + } + + /** + * Adds unigram information of a word to the dictionary. May overwrite an existing entry. + */ + public void addUnigramEntry(final String word, final int frequency, + final boolean isNotAWord, final boolean isPossiblyOffensive, final int timestamp) { + updateDictionaryWithWriteLock(new Runnable() { + @Override + public void run() { + addUnigramLocked(word, frequency, isNotAWord, isPossiblyOffensive, timestamp); + } + }); + } + + protected void addUnigramLocked(final String word, final int frequency, + final boolean isNotAWord, final boolean isPossiblyOffensive, final int timestamp) { + if (!mBinaryDictionary.addUnigramEntry(word, frequency, + false /* isBeginningOfSentence */, isNotAWord, isPossiblyOffensive, timestamp)) { + Log.e(TAG, "Cannot add unigram entry. word: " + word); + } + } + + /** + * Dynamically remove the unigram entry from the dictionary. + */ + public void removeUnigramEntryDynamically(final String word) { + reloadDictionaryIfRequired(); + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + if (!binaryDictionary.removeUnigramEntry(word)) { + if (DEBUG) { + Log.i(TAG, "Cannot remove unigram entry: " + word); + } + } + } + }); + } + + /** + * Adds n-gram information of a word to the dictionary. May overwrite an existing entry. + */ + public void addNgramEntry(@Nonnull final NgramContext ngramContext, final String word, + final int frequency, final int timestamp) { + reloadDictionaryIfRequired(); + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + if (getBinaryDictionary() == null) { + return; + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addNgramEntryLocked(ngramContext, word, frequency, timestamp); + } + }); + } + + protected void addNgramEntryLocked(@Nonnull final NgramContext ngramContext, final String word, + final int frequency, final int timestamp) { + if (!mBinaryDictionary.addNgramEntry(ngramContext, word, frequency, timestamp)) { + if (DEBUG) { + Log.i(TAG, "Cannot add n-gram entry."); + Log.i(TAG, " NgramContext: " + ngramContext + ", word: " + word); + } + } + } + + /** + * Update dictionary for the word with the ngramContext. + */ + public void updateEntriesForWord(@Nonnull final NgramContext ngramContext, + final String word, final boolean isValidWord, final int count, final int timestamp) { + updateDictionaryWithWriteLock(new Runnable() { + @Override + public void run() { + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + if (!binaryDictionary.updateEntriesForWordWithNgramContext(ngramContext, word, + isValidWord, count, timestamp)) { + if (DEBUG) { + Log.e(TAG, "Cannot update counter. word: " + word + + " context: " + ngramContext.toString()); + } + } + } + }); + } + + /** + * Used by Sketch. + * {@see https://cs.corp.google.com/#android/vendor/unbundled_google/packages/LatinIMEGoogle/tools/sketch/ime-simulator/src/org.kelar.inputmethod/sketch/imesimulator/ImeSimulator.java&q=updateEntriesForInputEventsCallback&l=286} + */ + @UsedForTesting + public interface UpdateEntriesForInputEventsCallback { + public void onFinished(); + } + + /** + * Dynamically update entries according to input events. + * + * Used by Sketch. + * {@see https://cs.corp.google.com/#android/vendor/unbundled_google/packages/LatinIMEGoogle/tools/sketch/ime-simulator/src/org.kelar.inputmethod/sketch/imesimulator/ImeSimulator.java&q=updateEntriesForInputEventsCallback&l=286} + */ + @UsedForTesting + public void updateEntriesForInputEvents( + @Nonnull final ArrayList<WordInputEventForPersonalization> inputEvents, + final UpdateEntriesForInputEventsCallback callback) { + reloadDictionaryIfRequired(); + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + try { + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + binaryDictionary.updateEntriesForInputEvents( + inputEvents.toArray( + new WordInputEventForPersonalization[inputEvents.size()])); + } finally { + if (callback != null) { + callback.onFinished(); + } + } + } + }); + } + + @Override + public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, final int sessionId, + final float weightForLocale, final float[] inOutWeightOfLangModelVsSpatialModel) { + reloadDictionaryIfRequired(); + boolean lockAcquired = false; + try { + lockAcquired = mLock.readLock().tryLock( + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + if (lockAcquired) { + if (mBinaryDictionary == null) { + return null; + } + final ArrayList<SuggestedWordInfo> suggestions = + mBinaryDictionary.getSuggestions(composedData, ngramContext, + proximityInfoHandle, settingsValuesForSuggestion, sessionId, + weightForLocale, inOutWeightOfLangModelVsSpatialModel); + if (mBinaryDictionary.isCorrupted()) { + Log.i(TAG, "Dictionary (" + mDictName +") is corrupted. " + + "Remove and regenerate it."); + removeBinaryDictionary(); + } + return suggestions; + } + } catch (final InterruptedException e) { + Log.e(TAG, "Interrupted tryLock() in getSuggestionsWithSessionId().", e); + } finally { + if (lockAcquired) { + mLock.readLock().unlock(); + } + } + return null; + } + + @Override + public boolean isInDictionary(final String word) { + reloadDictionaryIfRequired(); + boolean lockAcquired = false; + try { + lockAcquired = mLock.readLock().tryLock( + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + if (lockAcquired) { + if (mBinaryDictionary == null) { + return false; + } + return isInDictionaryLocked(word); + } + } catch (final InterruptedException e) { + Log.e(TAG, "Interrupted tryLock() in isInDictionary().", e); + } finally { + if (lockAcquired) { + mLock.readLock().unlock(); + } + } + return false; + } + + protected boolean isInDictionaryLocked(final String word) { + if (mBinaryDictionary == null) return false; + return mBinaryDictionary.isInDictionary(word); + } + + @Override + public int getMaxFrequencyOfExactMatches(final String word) { + reloadDictionaryIfRequired(); + boolean lockAcquired = false; + try { + lockAcquired = mLock.readLock().tryLock( + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + if (lockAcquired) { + if (mBinaryDictionary == null) { + return NOT_A_PROBABILITY; + } + return mBinaryDictionary.getMaxFrequencyOfExactMatches(word); + } + } catch (final InterruptedException e) { + Log.e(TAG, "Interrupted tryLock() in getMaxFrequencyOfExactMatches().", e); + } finally { + if (lockAcquired) { + mLock.readLock().unlock(); + } + } + return NOT_A_PROBABILITY; + } + + + /** + * Loads the current binary dictionary from internal storage. Assumes the dictionary file + * exists. + */ + void loadBinaryDictionaryLocked() { + if (DBG_STRESS_TEST) { + // Test if this class does not cause problems when it takes long time to load binary + // dictionary. + try { + Log.w(TAG, "Start stress in loading: " + mDictName); + Thread.sleep(15000); + Log.w(TAG, "End stress in loading"); + } catch (InterruptedException e) { + Log.w("Interrupted while loading: " + mDictName, e); + } + } + final BinaryDictionary oldBinaryDictionary = mBinaryDictionary; + openBinaryDictionaryLocked(); + if (oldBinaryDictionary != null) { + oldBinaryDictionary.close(); + } + if (mBinaryDictionary.isValidDictionary() + && needsToMigrateDictionary(mBinaryDictionary.getFormatVersion())) { + if (!mBinaryDictionary.migrateTo(DICTIONARY_FORMAT_VERSION)) { + Log.e(TAG, "Dictionary migration failed: " + mDictName); + removeBinaryDictionaryLocked(); + } + } + } + + /** + * Create a new binary dictionary and load initial contents. + */ + void createNewDictionaryLocked() { + removeBinaryDictionaryLocked(); + createOnMemoryBinaryDictionaryLocked(); + loadInitialContentsLocked(); + // Run GC and flush to file when initial contents have been loaded. + mBinaryDictionary.flushWithGCIfHasUpdated(); + } + + /** + * Marks that the dictionary needs to be recreated. + * + */ + protected void setNeedsToRecreate() { + mNeedsToRecreate = true; + } + + void clearNeedsToRecreate() { + mNeedsToRecreate = false; + } + + boolean isNeededToRecreate() { + return mNeedsToRecreate; + } + + /** + * Load the current binary dictionary from internal storage. If the dictionary file doesn't + * exists or needs to be regenerated, the new dictionary file will be asynchronously generated. + * However, the dictionary itself is accessible even before the new dictionary file is actually + * generated. It may return a null result for getSuggestions() in that case by design. + */ + public final void reloadDictionaryIfRequired() { + if (!isReloadRequired()) return; + asyncReloadDictionary(); + } + + /** + * Returns whether a dictionary reload is required. + */ + private boolean isReloadRequired() { + return mBinaryDictionary == null || mNeedsToRecreate; + } + + /** + * Reloads the dictionary. Access is controlled on a per dictionary file basis. + */ + private void asyncReloadDictionary() { + final AtomicBoolean isReloading = mIsReloading; + if (!isReloading.compareAndSet(false, true)) { + return; + } + final File dictFile = mDictFile; + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + try { + if (!dictFile.exists() || isNeededToRecreate()) { + // If the dictionary file does not exist or contents have been updated, + // generate a new one. + createNewDictionaryLocked(); + } else if (getBinaryDictionary() == null) { + // Otherwise, load the existing dictionary. + loadBinaryDictionaryLocked(); + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary != null && !(isValidDictionaryLocked() + // TODO: remove the check below + && matchesExpectedBinaryDictFormatVersionForThisType( + binaryDictionary.getFormatVersion()))) { + // Binary dictionary or its format version is not valid. Regenerate + // the dictionary file. createNewDictionaryLocked will remove the + // existing files if appropriate. + createNewDictionaryLocked(); + } + } + clearNeedsToRecreate(); + } finally { + isReloading.set(false); + } + } + }); + } + + /** + * Flush binary dictionary to dictionary file. + */ + public void asyncFlushBinaryDictionary() { + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + if (binaryDictionary.needsToRunGC(false /* mindsBlockByGC */)) { + binaryDictionary.flushWithGC(); + } else { + binaryDictionary.flush(); + } + } + }); + } + + public DictionaryStats getDictionaryStats() { + reloadDictionaryIfRequired(); + final String dictName = mDictName; + final File dictFile = mDictFile; + final AsyncResultHolder<DictionaryStats> result = + new AsyncResultHolder<>("DictionaryStats"); + asyncExecuteTaskWithLock(mLock.readLock(), new Runnable() { + @Override + public void run() { + result.set(new DictionaryStats(mLocale, dictName, dictName, dictFile, 0)); + } + }); + return result.get(null /* defaultValue */, TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS); + } + + @UsedForTesting + public void waitAllTasksForTests() { + final CountDownLatch countDownLatch = new CountDownLatch(1); + asyncExecuteTaskWithWriteLock(new Runnable() { + @Override + public void run() { + countDownLatch.countDown(); + } + }); + try { + countDownLatch.await(); + } catch (InterruptedException e) { + Log.e(TAG, "Interrupted while waiting for finishing dictionary operations.", e); + } + } + + @UsedForTesting + public void clearAndFlushDictionaryWithAdditionalAttributes( + final Map<String, String> attributeMap) { + mAdditionalAttributeMap = attributeMap; + clear(); + } + + public void dumpAllWordsForDebug() { + reloadDictionaryIfRequired(); + final String tag = TAG; + final String dictName = mDictName; + asyncExecuteTaskWithLock(mLock.readLock(), new Runnable() { + @Override + public void run() { + Log.d(tag, "Dump dictionary: " + dictName + " for " + mLocale); + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + try { + final DictionaryHeader header = binaryDictionary.getHeader(); + Log.d(tag, "Format version: " + binaryDictionary.getFormatVersion()); + Log.d(tag, CombinedFormatUtils.formatAttributeMap( + header.mDictionaryOptions.mAttributes)); + } catch (final UnsupportedFormatException e) { + Log.d(tag, "Cannot fetch header information.", e); + } + int token = 0; + do { + final BinaryDictionary.GetNextWordPropertyResult result = + binaryDictionary.getNextWordProperty(token); + final WordProperty wordProperty = result.mWordProperty; + if (wordProperty == null) { + Log.d(tag, " dictionary is empty."); + break; + } + Log.d(tag, wordProperty.toString()); + token = result.mNextToken; + } while (token != 0); + } + }); + } + + /** + * Returns dictionary content required for syncing. + */ + public WordProperty[] getWordPropertiesForSyncing() { + reloadDictionaryIfRequired(); + final AsyncResultHolder<WordProperty[]> result = + new AsyncResultHolder<>("WordPropertiesForSync"); + asyncExecuteTaskWithLock(mLock.readLock(), new Runnable() { + @Override + public void run() { + final ArrayList<WordProperty> wordPropertyList = new ArrayList<>(); + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; + } + int token = 0; + do { + // TODO: We need a new API that returns *new* un-synced data. + final BinaryDictionary.GetNextWordPropertyResult nextWordPropertyResult = + binaryDictionary.getNextWordProperty(token); + final WordProperty wordProperty = nextWordPropertyResult.mWordProperty; + if (wordProperty == null) { + break; + } + wordPropertyList.add(wordProperty); + token = nextWordPropertyResult.mNextToken; + } while (token != 0); + result.set(wordPropertyList.toArray(new WordProperty[wordPropertyList.size()])); + } + }); + // TODO: Figure out the best timeout duration for this API. + return result.get(DEFAULT_WORD_PROPERTIES_FOR_SYNC, + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/InputAttributes.java b/java/src/org/kelar/inputmethod/latin/InputAttributes.java new file mode 100644 index 000000000..0c145e543 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/InputAttributes.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_FLOATING_GESTURE_PREVIEW; +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_MICROPHONE; +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_MICROPHONE_COMPAT; + +import android.text.InputType; +import android.util.Log; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.utils.InputTypeUtils; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Class to hold attributes of the input field. + */ +public final class InputAttributes { + private final String TAG = InputAttributes.class.getSimpleName(); + + final public String mTargetApplicationPackageName; + final public boolean mInputTypeNoAutoCorrect; + final public boolean mIsPasswordField; + final public boolean mShouldShowSuggestions; + final public boolean mApplicationSpecifiedCompletionOn; + final public boolean mShouldInsertSpacesAutomatically; + final public boolean mShouldShowVoiceInputKey; + /** + * Whether the floating gesture preview should be disabled. If true, this should override the + * corresponding keyboard settings preference, always suppressing the floating preview text. + * {@link SettingsValues#mGestureFloatingPreviewTextEnabled} + */ + final public boolean mDisableGestureFloatingPreviewText; + final public boolean mIsGeneralTextInput; + final private int mInputType; + final private EditorInfo mEditorInfo; + final private String mPackageNameForPrivateImeOptions; + + public InputAttributes(final EditorInfo editorInfo, final boolean isFullscreenMode, + final String packageNameForPrivateImeOptions) { + mEditorInfo = editorInfo; + mPackageNameForPrivateImeOptions = packageNameForPrivateImeOptions; + mTargetApplicationPackageName = null != editorInfo ? editorInfo.packageName : null; + final int inputType = null != editorInfo ? editorInfo.inputType : 0; + final int inputClass = inputType & InputType.TYPE_MASK_CLASS; + mInputType = inputType; + mIsPasswordField = InputTypeUtils.isPasswordInputType(inputType) + || InputTypeUtils.isVisiblePasswordInputType(inputType); + if (inputClass != InputType.TYPE_CLASS_TEXT) { + // If we are not looking at a TYPE_CLASS_TEXT field, the following strange + // cases may arise, so we do a couple validity checks for them. If it's a + // TYPE_CLASS_TEXT field, these special cases cannot happen, by construction + // of the flags. + if (null == editorInfo) { + Log.w(TAG, "No editor info for this field. Bug?"); + } else if (InputType.TYPE_NULL == inputType) { + // TODO: We should honor TYPE_NULL specification. + Log.i(TAG, "InputType.TYPE_NULL is specified"); + } else if (inputClass == 0) { + // TODO: is this check still necessary? + Log.w(TAG, String.format("Unexpected input class: inputType=0x%08x" + + " imeOptions=0x%08x", inputType, editorInfo.imeOptions)); + } + mShouldShowSuggestions = false; + mInputTypeNoAutoCorrect = false; + mApplicationSpecifiedCompletionOn = false; + mShouldInsertSpacesAutomatically = false; + mShouldShowVoiceInputKey = false; + mDisableGestureFloatingPreviewText = false; + mIsGeneralTextInput = false; + return; + } + // inputClass == InputType.TYPE_CLASS_TEXT + final int variation = inputType & InputType.TYPE_MASK_VARIATION; + final boolean flagNoSuggestions = + 0 != (inputType & InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + final boolean flagMultiLine = + 0 != (inputType & InputType.TYPE_TEXT_FLAG_MULTI_LINE); + final boolean flagAutoCorrect = + 0 != (inputType & InputType.TYPE_TEXT_FLAG_AUTO_CORRECT); + final boolean flagAutoComplete = + 0 != (inputType & InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); + + // TODO: Have a helper method in InputTypeUtils + // Make sure that passwords are not displayed in {@link SuggestionStripView}. + final boolean shouldSuppressSuggestions = mIsPasswordField + || InputTypeUtils.isEmailVariation(variation) + || InputType.TYPE_TEXT_VARIATION_URI == variation + || InputType.TYPE_TEXT_VARIATION_FILTER == variation + || flagNoSuggestions + || flagAutoComplete; + mShouldShowSuggestions = !shouldSuppressSuggestions; + + mShouldInsertSpacesAutomatically = InputTypeUtils.isAutoSpaceFriendlyType(inputType); + + final boolean noMicrophone = mIsPasswordField + || InputTypeUtils.isEmailVariation(variation) + || InputType.TYPE_TEXT_VARIATION_URI == variation + || hasNoMicrophoneKeyOption(); + mShouldShowVoiceInputKey = !noMicrophone; + + mDisableGestureFloatingPreviewText = InputAttributes.inPrivateImeOptions( + mPackageNameForPrivateImeOptions, NO_FLOATING_GESTURE_PREVIEW, editorInfo); + + // If it's a browser edit field and auto correct is not ON explicitly, then + // disable auto correction, but keep suggestions on. + // If NO_SUGGESTIONS is set, don't do prediction. + // If it's not multiline and the autoCorrect flag is not set, then don't correct + mInputTypeNoAutoCorrect = + (variation == InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT && !flagAutoCorrect) + || flagNoSuggestions + || (!flagAutoCorrect && !flagMultiLine); + + mApplicationSpecifiedCompletionOn = flagAutoComplete && isFullscreenMode; + + // If we come here, inputClass is always TYPE_CLASS_TEXT + mIsGeneralTextInput = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS != variation + && InputType.TYPE_TEXT_VARIATION_PASSWORD != variation + && InputType.TYPE_TEXT_VARIATION_PHONETIC != variation + && InputType.TYPE_TEXT_VARIATION_URI != variation + && InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD != variation + && InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS != variation + && InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD != variation; + } + + public boolean isTypeNull() { + return InputType.TYPE_NULL == mInputType; + } + + public boolean isSameInputType(final EditorInfo editorInfo) { + return editorInfo.inputType == mInputType; + } + + private boolean hasNoMicrophoneKeyOption() { + @SuppressWarnings("deprecation") + final boolean deprecatedNoMicrophone = InputAttributes.inPrivateImeOptions( + null, NO_MICROPHONE_COMPAT, mEditorInfo); + final boolean noMicrophone = InputAttributes.inPrivateImeOptions( + mPackageNameForPrivateImeOptions, NO_MICROPHONE, mEditorInfo); + return noMicrophone || deprecatedNoMicrophone; + } + + @SuppressWarnings("unused") + private void dumpFlags(final int inputType) { + final int inputClass = inputType & InputType.TYPE_MASK_CLASS; + final String inputClassString = toInputClassString(inputClass); + final String variationString = toVariationString( + inputClass, inputType & InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + final String flagsString = toFlagsString(inputType & InputType.TYPE_MASK_FLAGS); + Log.i(TAG, "Input class: " + inputClassString); + Log.i(TAG, "Variation: " + variationString); + Log.i(TAG, "Flags: " + flagsString); + } + + private static String toInputClassString(final int inputClass) { + switch (inputClass) { + case InputType.TYPE_CLASS_TEXT: + return "TYPE_CLASS_TEXT"; + case InputType.TYPE_CLASS_PHONE: + return "TYPE_CLASS_PHONE"; + case InputType.TYPE_CLASS_NUMBER: + return "TYPE_CLASS_NUMBER"; + case InputType.TYPE_CLASS_DATETIME: + return "TYPE_CLASS_DATETIME"; + default: + return String.format("unknownInputClass<0x%08x>", inputClass); + } + } + + private static String toVariationString(final int inputClass, final int variation) { + switch (inputClass) { + case InputType.TYPE_CLASS_TEXT: + return toTextVariationString(variation); + case InputType.TYPE_CLASS_NUMBER: + return toNumberVariationString(variation); + case InputType.TYPE_CLASS_DATETIME: + return toDatetimeVariationString(variation); + default: + return ""; + } + } + + private static String toTextVariationString(final int variation) { + switch (variation) { + case InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS: + return " TYPE_TEXT_VARIATION_EMAIL_ADDRESS"; + case InputType.TYPE_TEXT_VARIATION_EMAIL_SUBJECT: + return "TYPE_TEXT_VARIATION_EMAIL_SUBJECT"; + case InputType.TYPE_TEXT_VARIATION_FILTER: + return "TYPE_TEXT_VARIATION_FILTER"; + case InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE: + return "TYPE_TEXT_VARIATION_LONG_MESSAGE"; + case InputType.TYPE_TEXT_VARIATION_NORMAL: + return "TYPE_TEXT_VARIATION_NORMAL"; + case InputType.TYPE_TEXT_VARIATION_PASSWORD: + return "TYPE_TEXT_VARIATION_PASSWORD"; + case InputType.TYPE_TEXT_VARIATION_PERSON_NAME: + return "TYPE_TEXT_VARIATION_PERSON_NAME"; + case InputType.TYPE_TEXT_VARIATION_PHONETIC: + return "TYPE_TEXT_VARIATION_PHONETIC"; + case InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS: + return "TYPE_TEXT_VARIATION_POSTAL_ADDRESS"; + case InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE: + return "TYPE_TEXT_VARIATION_SHORT_MESSAGE"; + case InputType.TYPE_TEXT_VARIATION_URI: + return "TYPE_TEXT_VARIATION_URI"; + case InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD: + return "TYPE_TEXT_VARIATION_VISIBLE_PASSWORD"; + case InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT: + return "TYPE_TEXT_VARIATION_WEB_EDIT_TEXT"; + case InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS: + return "TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS"; + case InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD: + return "TYPE_TEXT_VARIATION_WEB_PASSWORD"; + default: + return String.format("unknownVariation<0x%08x>", variation); + } + } + + private static String toNumberVariationString(final int variation) { + switch (variation) { + case InputType.TYPE_NUMBER_VARIATION_NORMAL: + return "TYPE_NUMBER_VARIATION_NORMAL"; + case InputType.TYPE_NUMBER_VARIATION_PASSWORD: + return "TYPE_NUMBER_VARIATION_PASSWORD"; + default: + return String.format("unknownVariation<0x%08x>", variation); + } + } + + private static String toDatetimeVariationString(final int variation) { + switch (variation) { + case InputType.TYPE_DATETIME_VARIATION_NORMAL: + return "TYPE_DATETIME_VARIATION_NORMAL"; + case InputType.TYPE_DATETIME_VARIATION_DATE: + return "TYPE_DATETIME_VARIATION_DATE"; + case InputType.TYPE_DATETIME_VARIATION_TIME: + return "TYPE_DATETIME_VARIATION_TIME"; + default: + return String.format("unknownVariation<0x%08x>", variation); + } + } + + private static String toFlagsString(final int flags) { + final ArrayList<String> flagsArray = new ArrayList<>(); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS)) + flagsArray.add("TYPE_TEXT_FLAG_NO_SUGGESTIONS"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_MULTI_LINE)) + flagsArray.add("TYPE_TEXT_FLAG_MULTI_LINE"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE)) + flagsArray.add("TYPE_TEXT_FLAG_IME_MULTI_LINE"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_CAP_WORDS)) + flagsArray.add("TYPE_TEXT_FLAG_CAP_WORDS"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)) + flagsArray.add("TYPE_TEXT_FLAG_CAP_SENTENCES"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS)) + flagsArray.add("TYPE_TEXT_FLAG_CAP_CHARACTERS"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_AUTO_CORRECT)) + flagsArray.add("TYPE_TEXT_FLAG_AUTO_CORRECT"); + if (0 != (flags & InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)) + flagsArray.add("TYPE_TEXT_FLAG_AUTO_COMPLETE"); + return flagsArray.isEmpty() ? "" : Arrays.toString(flagsArray.toArray()); + } + + // Pretty print + @Override + public String toString() { + return String.format( + "%s: inputType=0x%08x%s%s%s%s%s targetApp=%s\n", getClass().getSimpleName(), + mInputType, + (mInputTypeNoAutoCorrect ? " noAutoCorrect" : ""), + (mIsPasswordField ? " password" : ""), + (mShouldShowSuggestions ? " shouldShowSuggestions" : ""), + (mApplicationSpecifiedCompletionOn ? " appSpecified" : ""), + (mShouldInsertSpacesAutomatically ? " insertSpaces" : ""), + mTargetApplicationPackageName); + } + + public static boolean inPrivateImeOptions(final String packageName, final String key, + final EditorInfo editorInfo) { + if (editorInfo == null) return false; + final String findingKey = (packageName != null) ? packageName + "." + key : key; + return StringUtils.containsInCommaSplittableText(findingKey, editorInfo.privateImeOptions); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/InputView.java b/java/src/org/kelar/inputmethod/latin/InputView.java new file mode 100644 index 000000000..9aab0e7c7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/InputView.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.keyboard.MainKeyboardView; +import org.kelar.inputmethod.latin.suggestions.MoreSuggestionsView; +import org.kelar.inputmethod.latin.suggestions.SuggestionStripView; + +public final class InputView extends FrameLayout { + private final Rect mInputViewRect = new Rect(); + private MainKeyboardView mMainKeyboardView; + private KeyboardTopPaddingForwarder mKeyboardTopPaddingForwarder; + private MoreSuggestionsViewCanceler mMoreSuggestionsViewCanceler; + private MotionEventForwarder<?, ?> mActiveForwarder; + + public InputView(final Context context, final AttributeSet attrs) { + super(context, attrs, 0); + } + + @Override + protected void onFinishInflate() { + final SuggestionStripView suggestionStripView = + (SuggestionStripView)findViewById(R.id.suggestion_strip_view); + mMainKeyboardView = (MainKeyboardView)findViewById(R.id.keyboard_view); + mKeyboardTopPaddingForwarder = new KeyboardTopPaddingForwarder( + mMainKeyboardView, suggestionStripView); + mMoreSuggestionsViewCanceler = new MoreSuggestionsViewCanceler( + mMainKeyboardView, suggestionStripView); + } + + public void setKeyboardTopPadding(final int keyboardTopPadding) { + mKeyboardTopPaddingForwarder.setKeyboardTopPadding(keyboardTopPadding); + } + + @Override + protected boolean dispatchHoverEvent(final MotionEvent event) { + if (AccessibilityUtils.getInstance().isTouchExplorationEnabled() + && mMainKeyboardView.isShowingMoreKeysPanel()) { + // With accessibility mode on, discard hover events while a more keys keyboard is shown. + // The {@link MoreKeysKeyboard} receives hover events directly from the platform. + return true; + } + return super.dispatchHoverEvent(event); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent me) { + final Rect rect = mInputViewRect; + getGlobalVisibleRect(rect); + final int index = me.getActionIndex(); + final int x = (int)me.getX(index) + rect.left; + final int y = (int)me.getY(index) + rect.top; + + // The touch events that hit the top padding of keyboard should be forwarded to + // {@link SuggestionStripView}. + if (mKeyboardTopPaddingForwarder.onInterceptTouchEvent(x, y, me)) { + mActiveForwarder = mKeyboardTopPaddingForwarder; + return true; + } + + // To cancel {@link MoreSuggestionsView}, we should intercept a touch event to + // {@link MainKeyboardView} and dismiss the {@link MoreSuggestionsView}. + if (mMoreSuggestionsViewCanceler.onInterceptTouchEvent(x, y, me)) { + mActiveForwarder = mMoreSuggestionsViewCanceler; + return true; + } + + mActiveForwarder = null; + return false; + } + + @Override + public boolean onTouchEvent(final MotionEvent me) { + if (mActiveForwarder == null) { + return super.onTouchEvent(me); + } + + final Rect rect = mInputViewRect; + getGlobalVisibleRect(rect); + final int index = me.getActionIndex(); + final int x = (int)me.getX(index) + rect.left; + final int y = (int)me.getY(index) + rect.top; + return mActiveForwarder.onTouchEvent(x, y, me); + } + + /** + * This class forwards series of {@link MotionEvent}s from <code>SenderView</code> to + * <code>ReceiverView</code>. + * + * @param <SenderView> a {@link View} that may send a {@link MotionEvent} to <ReceiverView>. + * @param <ReceiverView> a {@link View} that receives forwarded {@link MotionEvent} from + * <SenderView>. + */ + private static abstract class + MotionEventForwarder<SenderView extends View, ReceiverView extends View> { + protected final SenderView mSenderView; + protected final ReceiverView mReceiverView; + + protected final Rect mEventSendingRect = new Rect(); + protected final Rect mEventReceivingRect = new Rect(); + + public MotionEventForwarder(final SenderView senderView, final ReceiverView receiverView) { + mSenderView = senderView; + mReceiverView = receiverView; + } + + // Return true if a touch event of global coordinate x, y needs to be forwarded. + protected abstract boolean needsToForward(final int x, final int y); + + // Translate global x-coordinate to <code>ReceiverView</code> local coordinate. + protected int translateX(final int x) { + return x - mEventReceivingRect.left; + } + + // Translate global y-coordinate to <code>ReceiverView</code> local coordinate. + protected int translateY(final int y) { + return y - mEventReceivingRect.top; + } + + /** + * Callback when a {@link MotionEvent} is forwarded. + * @param me the motion event to be forwarded. + */ + protected void onForwardingEvent(final MotionEvent me) {} + + // Returns true if a {@link MotionEvent} is needed to be forwarded to + // <code>ReceiverView</code>. Otherwise returns false. + public boolean onInterceptTouchEvent(final int x, final int y, final MotionEvent me) { + // Forwards a {link MotionEvent} only if both <code>SenderView</code> and + // <code>ReceiverView</code> are visible. + if (mSenderView.getVisibility() != View.VISIBLE || + mReceiverView.getVisibility() != View.VISIBLE) { + return false; + } + mSenderView.getGlobalVisibleRect(mEventSendingRect); + if (!mEventSendingRect.contains(x, y)) { + return false; + } + + if (me.getActionMasked() == MotionEvent.ACTION_DOWN) { + // If the down event happens in the forwarding area, successive + // {@link MotionEvent}s should be forwarded to <code>ReceiverView</code>. + if (needsToForward(x, y)) { + return true; + } + } + + return false; + } + + // Returns true if a {@link MotionEvent} is forwarded to <code>ReceiverView</code>. + // Otherwise returns false. + public boolean onTouchEvent(final int x, final int y, final MotionEvent me) { + mReceiverView.getGlobalVisibleRect(mEventReceivingRect); + // Translate global coordinates to <code>ReceiverView</code> local coordinates. + me.setLocation(translateX(x), translateY(y)); + mReceiverView.dispatchTouchEvent(me); + onForwardingEvent(me); + return true; + } + } + + /** + * This class forwards {@link MotionEvent}s happened in the top padding of + * {@link MainKeyboardView} to {@link SuggestionStripView}. + */ + private static class KeyboardTopPaddingForwarder + extends MotionEventForwarder<MainKeyboardView, SuggestionStripView> { + private int mKeyboardTopPadding; + + public KeyboardTopPaddingForwarder(final MainKeyboardView mainKeyboardView, + final SuggestionStripView suggestionStripView) { + super(mainKeyboardView, suggestionStripView); + } + + public void setKeyboardTopPadding(final int keyboardTopPadding) { + mKeyboardTopPadding = keyboardTopPadding; + } + + private boolean isInKeyboardTopPadding(final int y) { + return y < mEventSendingRect.top + mKeyboardTopPadding; + } + + @Override + protected boolean needsToForward(final int x, final int y) { + // Forwarding an event only when {@link MainKeyboardView} is visible. + // Because the visibility of {@link MainKeyboardView} is controlled by its parent + // view in {@link KeyboardSwitcher#setMainKeyboardFrame()}, we should check the + // visibility of the parent view. + final View mainKeyboardFrame = (View)mSenderView.getParent(); + return mainKeyboardFrame.getVisibility() == View.VISIBLE && isInKeyboardTopPadding(y); + } + + @Override + protected int translateY(final int y) { + final int translatedY = super.translateY(y); + if (isInKeyboardTopPadding(y)) { + // The forwarded event should have coordinates that are inside of the target. + return Math.min(translatedY, mEventReceivingRect.height() - 1); + } + return translatedY; + } + } + + /** + * This class forwards {@link MotionEvent}s happened in the {@link MainKeyboardView} to + * {@link SuggestionStripView} when the {@link MoreSuggestionsView} is showing. + * {@link SuggestionStripView} dismisses {@link MoreSuggestionsView} when it receives any event + * outside of it. + */ + private static class MoreSuggestionsViewCanceler + extends MotionEventForwarder<MainKeyboardView, SuggestionStripView> { + public MoreSuggestionsViewCanceler(final MainKeyboardView mainKeyboardView, + final SuggestionStripView suggestionStripView) { + super(mainKeyboardView, suggestionStripView); + } + + @Override + protected boolean needsToForward(final int x, final int y) { + return mReceiverView.isShowingMoreSuggestionPanel() && mEventSendingRect.contains(x, y); + } + + @Override + protected void onForwardingEvent(final MotionEvent me) { + if (me.getActionMasked() == MotionEvent.ACTION_DOWN) { + mReceiverView.dismissMoreSuggestionsPanel(); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/LastComposedWord.java b/java/src/org/kelar/inputmethod/latin/LastComposedWord.java new file mode 100644 index 000000000..784518822 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/LastComposedWord.java @@ -0,0 +1,93 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.text.TextUtils; + +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; + +import java.util.ArrayList; + +/** + * This class encapsulates data about a word previously composed, but that has been + * committed already. This is used for resuming suggestion, and cancel auto-correction. + */ +public final class LastComposedWord { + // COMMIT_TYPE_USER_TYPED_WORD is used when the word committed is the exact typed word, with + // no hinting from the IME. It happens when some external event happens (rotating the device, + // for example) or when auto-correction is off by settings or editor attributes. + public static final int COMMIT_TYPE_USER_TYPED_WORD = 0; + // COMMIT_TYPE_MANUAL_PICK is used when the user pressed a field in the suggestion strip. + public static final int COMMIT_TYPE_MANUAL_PICK = 1; + // COMMIT_TYPE_DECIDED_WORD is used when the IME commits the word it decided was best + // for the current user input. It may be different from what the user typed (true auto-correct) + // or it may be exactly what the user typed if it's in the dictionary or the IME does not have + // enough confidence in any suggestion to auto-correct (auto-correct to typed word). + public static final int COMMIT_TYPE_DECIDED_WORD = 2; + // COMMIT_TYPE_CANCEL_AUTO_CORRECT is used upon committing back the old word upon cancelling + // an auto-correction. + public static final int COMMIT_TYPE_CANCEL_AUTO_CORRECT = 3; + + public static final String NOT_A_SEPARATOR = ""; + + public final ArrayList<Event> mEvents; + public final String mTypedWord; + public final CharSequence mCommittedWord; + public final String mSeparatorString; + public final NgramContext mNgramContext; + public final int mCapitalizedMode; + public final InputPointers mInputPointers = + new InputPointers(DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH); + + private boolean mActive; + + public static final LastComposedWord NOT_A_COMPOSED_WORD = + new LastComposedWord(new ArrayList<Event>(), null, "", "", + NOT_A_SEPARATOR, null, WordComposer.CAPS_MODE_OFF); + + // Warning: this is using the passed objects as is and fully expects them to be + // immutable. Do not fiddle with their contents after you passed them to this constructor. + public LastComposedWord(final ArrayList<Event> events, + final InputPointers inputPointers, final String typedWord, + final CharSequence committedWord, final String separatorString, + final NgramContext ngramContext, final int capitalizedMode) { + if (inputPointers != null) { + mInputPointers.copy(inputPointers); + } + mTypedWord = typedWord; + mEvents = new ArrayList<>(events); + mCommittedWord = committedWord; + mSeparatorString = separatorString; + mActive = true; + mNgramContext = ngramContext; + mCapitalizedMode = capitalizedMode; + } + + public void deactivate() { + mActive = false; + } + + public boolean canRevertCommit() { + return mActive && !TextUtils.isEmpty(mCommittedWord) && !didCommitTypedWord(); + } + + private boolean didCommitTypedWord() { + return TextUtils.equals(mTypedWord, mCommittedWord); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/LatinIME.java b/java/src/org/kelar/inputmethod/latin/LatinIME.java new file mode 100644 index 000000000..5529c2bf5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/LatinIME.java @@ -0,0 +1,2033 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.FORCE_ASCII; +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_MICROPHONE; +import static org.kelar.inputmethod.latin.common.Constants.ImeOption.NO_MICROPHONE_COMPAT; + +import android.Manifest.permission; +import android.app.ActivityOptions; +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; +import android.inputmethodservice.InputMethodService; +import android.media.AudioManager; +import android.os.Build; +import android.os.Debug; +import android.os.IBinder; +import android.os.Message; +import android.preference.PreferenceManager; +import android.text.InputType; +import android.util.Log; +import android.util.PrintWriterPrinter; +import android.util.Printer; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.CompletionInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodSubtype; + +import androidx.annotation.NonNull; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.compat.EditorInfoCompatUtils; +import org.kelar.inputmethod.compat.InputMethodServiceCompatUtils; +import org.kelar.inputmethod.compat.ViewOutlineProviderCompatUtils; +import org.kelar.inputmethod.compat.ViewOutlineProviderCompatUtils.InsetsUpdater; +import org.kelar.inputmethod.dictionarypack.DictionaryPackConstants; +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.event.HardwareEventDecoder; +import org.kelar.inputmethod.event.HardwareKeyboardEventDecoder; +import org.kelar.inputmethod.event.InputTransaction; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardActionListener; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.keyboard.KeyboardSwitcher; +import org.kelar.inputmethod.keyboard.MainKeyboardView; +import org.kelar.inputmethod.latin.Suggest.OnGetSuggestedWordsCallback; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.define.ProductionFlags; +import org.kelar.inputmethod.latin.inputlogic.InputLogic; +import org.kelar.inputmethod.latin.permissions.PermissionsManager; +import org.kelar.inputmethod.latin.personalization.PersonalizationHelper; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.settings.SettingsActivity; +import org.kelar.inputmethod.latin.settings.SettingsValues; +import org.kelar.inputmethod.latin.suggestions.SuggestionStripView; +import org.kelar.inputmethod.latin.suggestions.SuggestionStripViewAccessor; +import org.kelar.inputmethod.latin.touchinputconsumer.GestureConsumer; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.DialogUtils; +import org.kelar.inputmethod.latin.utils.ImportantNoticeUtils; +import org.kelar.inputmethod.latin.utils.IntentUtils; +import org.kelar.inputmethod.latin.utils.JniUtils; +import org.kelar.inputmethod.latin.utils.LeakGuardHandlerWrapper; +import org.kelar.inputmethod.latin.utils.StatsUtils; +import org.kelar.inputmethod.latin.utils.StatsUtilsManager; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; +import org.kelar.inputmethod.latin.utils.ViewLayoutUtils; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Input method implementation for Qwerty'ish keyboard. + */ +public class LatinIME extends InputMethodService implements KeyboardActionListener, + SuggestionStripView.Listener, SuggestionStripViewAccessor, + DictionaryFacilitator.DictionaryInitializationListener, + PermissionsManager.PermissionsResultCallback { + static final String TAG = LatinIME.class.getSimpleName(); + private static final boolean TRACE = false; + + private static final int PERIOD_FOR_AUDIO_AND_HAPTIC_FEEDBACK_IN_KEY_REPEAT = 2; + private static final int PENDING_IMS_CALLBACK_DURATION_MILLIS = 800; + static final long DELAY_WAIT_FOR_DICTIONARY_LOAD_MILLIS = TimeUnit.SECONDS.toMillis(2); + static final long DELAY_DEALLOCATE_MEMORY_MILLIS = TimeUnit.SECONDS.toMillis(10); + + /** + * A broadcast intent action to hide the software keyboard. + */ + static final String ACTION_HIDE_SOFT_INPUT = + "org.kelar.inputmethod.latin.HIDE_SOFT_INPUT"; + + /** + * A custom permission for external apps to send {@link #ACTION_HIDE_SOFT_INPUT}. + */ + static final String PERMISSION_HIDE_SOFT_INPUT = + "org.kelar.inputmethod.latin.HIDE_SOFT_INPUT"; + + /** + * The name of the scheme used by the Package Manager to warn of a new package installation, + * replacement or removal. + */ + private static final String SCHEME_PACKAGE = "package"; + + final Settings mSettings; + private final DictionaryFacilitator mDictionaryFacilitator = + DictionaryFacilitatorProvider.getDictionaryFacilitator( + false /* isNeededForSpellChecking */); + final InputLogic mInputLogic = new InputLogic(this /* LatinIME */, + this /* SuggestionStripViewAccessor */, mDictionaryFacilitator); + // We expect to have only one decoder in almost all cases, hence the default capacity of 1. + // If it turns out we need several, it will get grown seamlessly. + final SparseArray<HardwareEventDecoder> mHardwareEventDecoders = new SparseArray<>(1); + + // TODO: Move these {@link View}s to {@link KeyboardSwitcher}. + private View mInputView; + private InsetsUpdater mInsetsUpdater; + private SuggestionStripView mSuggestionStripView; + + private RichInputMethodManager mRichImm; + @UsedForTesting final KeyboardSwitcher mKeyboardSwitcher; + private final SubtypeState mSubtypeState = new SubtypeState(); + private EmojiAltPhysicalKeyDetector mEmojiAltPhysicalKeyDetector; + private StatsUtilsManager mStatsUtilsManager; + // Working variable for {@link #startShowingInputView()} and + // {@link #onEvaluateInputViewShown()}. + private boolean mIsExecutingStartShowingInputView; + + // Used for re-initialize keyboard layout after onConfigurationChange. + @Nullable private Context mDisplayContext; + + // Object for reacting to adding/removing a dictionary pack. + private final BroadcastReceiver mDictionaryPackInstallReceiver = + new DictionaryPackInstallBroadcastReceiver(this); + + private final BroadcastReceiver mDictionaryDumpBroadcastReceiver = + new DictionaryDumpBroadcastReceiver(this); + + final static class HideSoftInputReceiver extends BroadcastReceiver { + private final InputMethodService mIms; + + public HideSoftInputReceiver(InputMethodService ims) { + mIms = ims; + } + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (ACTION_HIDE_SOFT_INPUT.equals(action)) { + mIms.requestHideSelf(0 /* flags */); + } else { + Log.e(TAG, "Unexpected intent " + intent); + } + } + } + final HideSoftInputReceiver mHideSoftInputReceiver = new HideSoftInputReceiver(this); + + private AlertDialog mOptionsDialog; + + private final boolean mIsHardwareAcceleratedDrawingEnabled; + + private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; + + public final UIHandler mHandler = new UIHandler(this); + + public static final class UIHandler extends LeakGuardHandlerWrapper<LatinIME> { + private static final int MSG_UPDATE_SHIFT_STATE = 0; + private static final int MSG_PENDING_IMS_CALLBACK = 1; + private static final int MSG_UPDATE_SUGGESTION_STRIP = 2; + private static final int MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP = 3; + private static final int MSG_RESUME_SUGGESTIONS = 4; + private static final int MSG_REOPEN_DICTIONARIES = 5; + private static final int MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED = 6; + private static final int MSG_RESET_CACHES = 7; + private static final int MSG_WAIT_FOR_DICTIONARY_LOAD = 8; + private static final int MSG_DEALLOCATE_MEMORY = 9; + private static final int MSG_RESUME_SUGGESTIONS_FOR_START_INPUT = 10; + private static final int MSG_SWITCH_LANGUAGE_AUTOMATICALLY = 11; + // Update this when adding new messages + private static final int MSG_LAST = MSG_SWITCH_LANGUAGE_AUTOMATICALLY; + + private static final int ARG1_NOT_GESTURE_INPUT = 0; + private static final int ARG1_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT = 1; + private static final int ARG1_SHOW_GESTURE_FLOATING_PREVIEW_TEXT = 2; + private static final int ARG2_UNUSED = 0; + private static final int ARG1_TRUE = 1; + + private int mDelayInMillisecondsToUpdateSuggestions; + private int mDelayInMillisecondsToUpdateShiftState; + + public UIHandler(@Nonnull final LatinIME ownerInstance) { + super(ownerInstance); + } + + public void onCreate() { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme == null) { + return; + } + final Resources res = latinIme.getResources(); + mDelayInMillisecondsToUpdateSuggestions = res.getInteger( + R.integer.config_delay_in_milliseconds_to_update_suggestions); + mDelayInMillisecondsToUpdateShiftState = res.getInteger( + R.integer.config_delay_in_milliseconds_to_update_shift_state); + } + + @Override + public void handleMessage(final Message msg) { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme == null) { + return; + } + final KeyboardSwitcher switcher = latinIme.mKeyboardSwitcher; + switch (msg.what) { + case MSG_UPDATE_SUGGESTION_STRIP: + cancelUpdateSuggestionStrip(); + latinIme.mInputLogic.performUpdateSuggestionStripSync( + latinIme.mSettings.getCurrent(), msg.arg1 /* inputStyle */); + break; + case MSG_UPDATE_SHIFT_STATE: + switcher.requestUpdatingShiftState(latinIme.getCurrentAutoCapsState(), + latinIme.getCurrentRecapitalizeState()); + break; + case MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP: + if (msg.arg1 == ARG1_NOT_GESTURE_INPUT) { + final SuggestedWords suggestedWords = (SuggestedWords) msg.obj; + latinIme.showSuggestionStrip(suggestedWords); + } else { + latinIme.showGesturePreviewAndSuggestionStrip((SuggestedWords) msg.obj, + msg.arg1 == ARG1_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT); + } + break; + case MSG_RESUME_SUGGESTIONS: + latinIme.mInputLogic.restartSuggestionsOnWordTouchedByCursor( + latinIme.mSettings.getCurrent(), false /* forStartInput */, + latinIme.mKeyboardSwitcher.getCurrentKeyboardScriptId()); + break; + case MSG_RESUME_SUGGESTIONS_FOR_START_INPUT: + latinIme.mInputLogic.restartSuggestionsOnWordTouchedByCursor( + latinIme.mSettings.getCurrent(), true /* forStartInput */, + latinIme.mKeyboardSwitcher.getCurrentKeyboardScriptId()); + break; + case MSG_REOPEN_DICTIONARIES: + // We need to re-evaluate the currently composing word in case the script has + // changed. + postWaitForDictionaryLoad(); + latinIme.resetDictionaryFacilitatorIfNecessary(); + break; + case MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED: + final SuggestedWords suggestedWords = (SuggestedWords) msg.obj; + latinIme.mInputLogic.onUpdateTailBatchInputCompleted( + latinIme.mSettings.getCurrent(), + suggestedWords, latinIme.mKeyboardSwitcher); + latinIme.onTailBatchInputResultShown(suggestedWords); + break; + case MSG_RESET_CACHES: + final SettingsValues settingsValues = latinIme.mSettings.getCurrent(); + if (latinIme.mInputLogic.retryResetCachesAndReturnSuccess( + msg.arg1 == ARG1_TRUE /* tryResumeSuggestions */, + msg.arg2 /* remainingTries */, this /* handler */)) { + // If we were able to reset the caches, then we can reload the keyboard. + // Otherwise, we'll do it when we can. + latinIme.mKeyboardSwitcher.loadKeyboard(latinIme.getCurrentInputEditorInfo(), + settingsValues, latinIme.getCurrentAutoCapsState(), + latinIme.getCurrentRecapitalizeState()); + } + break; + case MSG_WAIT_FOR_DICTIONARY_LOAD: + Log.i(TAG, "Timeout waiting for dictionary load"); + break; + case MSG_DEALLOCATE_MEMORY: + latinIme.deallocateMemory(); + break; + case MSG_SWITCH_LANGUAGE_AUTOMATICALLY: + latinIme.switchLanguage((InputMethodSubtype)msg.obj); + break; + } + } + + public void postUpdateSuggestionStrip(final int inputStyle) { + sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle, + 0 /* ignored */), mDelayInMillisecondsToUpdateSuggestions); + } + + public void postReopenDictionaries() { + sendMessage(obtainMessage(MSG_REOPEN_DICTIONARIES)); + } + + private void postResumeSuggestionsInternal(final boolean shouldDelay, + final boolean forStartInput) { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme == null) { + return; + } + if (!latinIme.mSettings.getCurrent().isSuggestionsEnabledPerUserSettings()) { + return; + } + removeMessages(MSG_RESUME_SUGGESTIONS); + removeMessages(MSG_RESUME_SUGGESTIONS_FOR_START_INPUT); + final int message = forStartInput ? MSG_RESUME_SUGGESTIONS_FOR_START_INPUT + : MSG_RESUME_SUGGESTIONS; + if (shouldDelay) { + sendMessageDelayed(obtainMessage(message), + mDelayInMillisecondsToUpdateSuggestions); + } else { + sendMessage(obtainMessage(message)); + } + } + + public void postResumeSuggestions(final boolean shouldDelay) { + postResumeSuggestionsInternal(shouldDelay, false /* forStartInput */); + } + + public void postResumeSuggestionsForStartInput(final boolean shouldDelay) { + postResumeSuggestionsInternal(shouldDelay, true /* forStartInput */); + } + + public void postResetCaches(final boolean tryResumeSuggestions, final int remainingTries) { + removeMessages(MSG_RESET_CACHES); + sendMessage(obtainMessage(MSG_RESET_CACHES, tryResumeSuggestions ? 1 : 0, + remainingTries, null)); + } + + public void postWaitForDictionaryLoad() { + sendMessageDelayed(obtainMessage(MSG_WAIT_FOR_DICTIONARY_LOAD), + DELAY_WAIT_FOR_DICTIONARY_LOAD_MILLIS); + } + + public void cancelWaitForDictionaryLoad() { + removeMessages(MSG_WAIT_FOR_DICTIONARY_LOAD); + } + + public boolean hasPendingWaitForDictionaryLoad() { + return hasMessages(MSG_WAIT_FOR_DICTIONARY_LOAD); + } + + public void cancelUpdateSuggestionStrip() { + removeMessages(MSG_UPDATE_SUGGESTION_STRIP); + } + + public boolean hasPendingUpdateSuggestions() { + return hasMessages(MSG_UPDATE_SUGGESTION_STRIP); + } + + public boolean hasPendingReopenDictionaries() { + return hasMessages(MSG_REOPEN_DICTIONARIES); + } + + public void postUpdateShiftState() { + removeMessages(MSG_UPDATE_SHIFT_STATE); + sendMessageDelayed(obtainMessage(MSG_UPDATE_SHIFT_STATE), + mDelayInMillisecondsToUpdateShiftState); + } + + public void postDeallocateMemory() { + sendMessageDelayed(obtainMessage(MSG_DEALLOCATE_MEMORY), + DELAY_DEALLOCATE_MEMORY_MILLIS); + } + + public void cancelDeallocateMemory() { + removeMessages(MSG_DEALLOCATE_MEMORY); + } + + public boolean hasPendingDeallocateMemory() { + return hasMessages(MSG_DEALLOCATE_MEMORY); + } + + @UsedForTesting + public void removeAllMessages() { + for (int i = 0; i <= MSG_LAST; ++i) { + removeMessages(i); + } + } + + public void showGesturePreviewAndSuggestionStrip(final SuggestedWords suggestedWords, + final boolean dismissGestureFloatingPreviewText) { + removeMessages(MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP); + final int arg1 = dismissGestureFloatingPreviewText + ? ARG1_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT + : ARG1_SHOW_GESTURE_FLOATING_PREVIEW_TEXT; + obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP, arg1, + ARG2_UNUSED, suggestedWords).sendToTarget(); + } + + public void showSuggestionStrip(final SuggestedWords suggestedWords) { + removeMessages(MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP); + obtainMessage(MSG_SHOW_GESTURE_PREVIEW_AND_SUGGESTION_STRIP, + ARG1_NOT_GESTURE_INPUT, ARG2_UNUSED, suggestedWords).sendToTarget(); + } + + public void showTailBatchInputResult(final SuggestedWords suggestedWords) { + obtainMessage(MSG_UPDATE_TAIL_BATCH_INPUT_COMPLETED, suggestedWords).sendToTarget(); + } + + public void postSwitchLanguage(final InputMethodSubtype subtype) { + obtainMessage(MSG_SWITCH_LANGUAGE_AUTOMATICALLY, subtype).sendToTarget(); + } + + // Working variables for the following methods. + private boolean mIsOrientationChanging; + private boolean mPendingSuccessiveImsCallback; + private boolean mHasPendingStartInput; + private boolean mHasPendingFinishInputView; + private boolean mHasPendingFinishInput; + private EditorInfo mAppliedEditorInfo; + + public void startOrientationChanging() { + removeMessages(MSG_PENDING_IMS_CALLBACK); + resetPendingImsCallback(); + mIsOrientationChanging = true; + final LatinIME latinIme = getOwnerInstance(); + if (latinIme == null) { + return; + } + if (latinIme.isInputViewShown()) { + latinIme.mKeyboardSwitcher.saveKeyboardState(); + } + } + + private void resetPendingImsCallback() { + mHasPendingFinishInputView = false; + mHasPendingFinishInput = false; + mHasPendingStartInput = false; + } + + private void executePendingImsCallback(final LatinIME latinIme, final EditorInfo editorInfo, + boolean restarting) { + if (mHasPendingFinishInputView) { + latinIme.onFinishInputViewInternal(mHasPendingFinishInput); + } + if (mHasPendingFinishInput) { + latinIme.onFinishInputInternal(); + } + if (mHasPendingStartInput) { + latinIme.onStartInputInternal(editorInfo, restarting); + } + resetPendingImsCallback(); + } + + public void onStartInput(final EditorInfo editorInfo, final boolean restarting) { + if (hasMessages(MSG_PENDING_IMS_CALLBACK)) { + // Typically this is the second onStartInput after orientation changed. + mHasPendingStartInput = true; + } else { + if (mIsOrientationChanging && restarting) { + // This is the first onStartInput after orientation changed. + mIsOrientationChanging = false; + mPendingSuccessiveImsCallback = true; + } + final LatinIME latinIme = getOwnerInstance(); + if (latinIme != null) { + executePendingImsCallback(latinIme, editorInfo, restarting); + latinIme.onStartInputInternal(editorInfo, restarting); + } + } + } + + public void onStartInputView(final EditorInfo editorInfo, final boolean restarting) { + if (hasMessages(MSG_PENDING_IMS_CALLBACK) + && KeyboardId.equivalentEditorInfoForKeyboard(editorInfo, mAppliedEditorInfo)) { + // Typically this is the second onStartInputView after orientation changed. + resetPendingImsCallback(); + } else { + if (mPendingSuccessiveImsCallback) { + // This is the first onStartInputView after orientation changed. + mPendingSuccessiveImsCallback = false; + resetPendingImsCallback(); + sendMessageDelayed(obtainMessage(MSG_PENDING_IMS_CALLBACK), + PENDING_IMS_CALLBACK_DURATION_MILLIS); + } + final LatinIME latinIme = getOwnerInstance(); + if (latinIme != null) { + executePendingImsCallback(latinIme, editorInfo, restarting); + latinIme.onStartInputViewInternal(editorInfo, restarting); + mAppliedEditorInfo = editorInfo; + } + cancelDeallocateMemory(); + } + } + + public void onFinishInputView(final boolean finishingInput) { + if (hasMessages(MSG_PENDING_IMS_CALLBACK)) { + // Typically this is the first onFinishInputView after orientation changed. + mHasPendingFinishInputView = true; + } else { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme != null) { + latinIme.onFinishInputViewInternal(finishingInput); + mAppliedEditorInfo = null; + } + if (!hasPendingDeallocateMemory()) { + postDeallocateMemory(); + } + } + } + + public void onFinishInput() { + if (hasMessages(MSG_PENDING_IMS_CALLBACK)) { + // Typically this is the first onFinishInput after orientation changed. + mHasPendingFinishInput = true; + } else { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme != null) { + executePendingImsCallback(latinIme, null, false); + latinIme.onFinishInputInternal(); + } + } + } + } + + static final class SubtypeState { + private InputMethodSubtype mLastActiveSubtype; + private boolean mCurrentSubtypeHasBeenUsed; + + public void setCurrentSubtypeHasBeenUsed() { + mCurrentSubtypeHasBeenUsed = true; + } + + public void switchSubtype(final IBinder token, final RichInputMethodManager richImm) { + final InputMethodSubtype currentSubtype = richImm.getInputMethodManager() + .getCurrentInputMethodSubtype(); + final InputMethodSubtype lastActiveSubtype = mLastActiveSubtype; + final boolean currentSubtypeHasBeenUsed = mCurrentSubtypeHasBeenUsed; + if (currentSubtypeHasBeenUsed) { + mLastActiveSubtype = currentSubtype; + mCurrentSubtypeHasBeenUsed = false; + } + if (currentSubtypeHasBeenUsed + && richImm.checkIfSubtypeBelongsToThisImeAndEnabled(lastActiveSubtype) + && !currentSubtype.equals(lastActiveSubtype)) { + richImm.setInputMethodAndSubtype(token, lastActiveSubtype); + return; + } + richImm.switchToNextInputMethod(token, true /* onlyCurrentIme */); + } + } + + // Loading the native library eagerly to avoid unexpected UnsatisfiedLinkError at the initial + // JNI call as much as possible. + static { + JniUtils.loadNativeLibrary(); + } + + public LatinIME() { + super(); + mSettings = Settings.getInstance(); + mKeyboardSwitcher = KeyboardSwitcher.getInstance(); + mStatsUtilsManager = StatsUtilsManager.getInstance(); + mIsHardwareAcceleratedDrawingEnabled = + InputMethodServiceCompatUtils.enableHardwareAcceleration(this); + Log.i(TAG, "Hardware accelerated drawing: " + mIsHardwareAcceleratedDrawingEnabled); + } + + @Override + public void onCreate() { + Settings.init(this); + DebugFlags.init(PreferenceManager.getDefaultSharedPreferences(this)); + RichInputMethodManager.init(this); + mRichImm = RichInputMethodManager.getInstance(); + AudioAndHapticFeedbackManager.init(this); + AccessibilityUtils.init(this); + mStatsUtilsManager.onCreate(this /* context */, mDictionaryFacilitator); + final WindowManager wm = getSystemService(WindowManager.class); + mDisplayContext = getDisplayContext(); + KeyboardSwitcher.init(this); + super.onCreate(); + + mHandler.onCreate(); + + // TODO: Resolve mutual dependencies of {@link #loadSettings()} and + // {@link #resetDictionaryFacilitatorIfNecessary()}. + loadSettings(); + resetDictionaryFacilitatorIfNecessary(); + + // Register to receive ringer mode change. + final IntentFilter filter = new IntentFilter(); + filter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION); + registerReceiver(mRingerModeChangeReceiver, filter); + + // Register to receive installation and removal of a dictionary pack. + final IntentFilter packageFilter = new IntentFilter(); + packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); + packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); + packageFilter.addDataScheme(SCHEME_PACKAGE); + registerReceiver(mDictionaryPackInstallReceiver, packageFilter); + + final IntentFilter newDictFilter = new IntentFilter(); + newDictFilter.addAction(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(mDictionaryPackInstallReceiver, newDictFilter, + Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(mDictionaryPackInstallReceiver, newDictFilter); + } + + final IntentFilter dictDumpFilter = new IntentFilter(); + dictDumpFilter.addAction(DictionaryDumpBroadcastReceiver.DICTIONARY_DUMP_INTENT_ACTION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(mDictionaryDumpBroadcastReceiver, dictDumpFilter, + Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(mDictionaryDumpBroadcastReceiver, dictDumpFilter); + } + + final IntentFilter hideSoftInputFilter = new IntentFilter(); + hideSoftInputFilter.addAction(ACTION_HIDE_SOFT_INPUT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(mHideSoftInputReceiver, hideSoftInputFilter, + PERMISSION_HIDE_SOFT_INPUT, null /* scheduler */, Context.RECEIVER_EXPORTED); + } else { + registerReceiver(mHideSoftInputReceiver, hideSoftInputFilter, + PERMISSION_HIDE_SOFT_INPUT, null /* scheduler */); + } + + StatsUtils.onCreate(mSettings.getCurrent(), mRichImm); + } + + // Has to be package-visible for unit tests + @UsedForTesting + void loadSettings() { + final Locale locale = mRichImm.getCurrentSubtypeLocale(); + final EditorInfo editorInfo = getCurrentInputEditorInfo(); + final InputAttributes inputAttributes = new InputAttributes( + editorInfo, isFullscreenMode(), getPackageName()); + mSettings.loadSettings(this, locale, inputAttributes); + final SettingsValues currentSettingsValues = mSettings.getCurrent(); + AudioAndHapticFeedbackManager.getInstance().onSettingsChanged(currentSettingsValues); + // This method is called on startup and language switch, before the new layout has + // been displayed. Opening dictionaries never affects responsivity as dictionaries are + // asynchronously loaded. + if (!mHandler.hasPendingReopenDictionaries()) { + resetDictionaryFacilitator(locale); + } + refreshPersonalizationDictionarySession(currentSettingsValues); + resetDictionaryFacilitatorIfNecessary(); + mStatsUtilsManager.onLoadSettings(this /* context */, currentSettingsValues); + } + + private void refreshPersonalizationDictionarySession( + final SettingsValues currentSettingsValues) { + if (!currentSettingsValues.mUsePersonalizedDicts) { + // Remove user history dictionaries. + PersonalizationHelper.removeAllUserHistoryDictionaries(this); + mDictionaryFacilitator.clearUserHistoryDictionary(this); + } + } + + // Note that this method is called from a non-UI thread. + @Override + public void onUpdateMainDictionaryAvailability(final boolean isMainDictionaryAvailable) { + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + if (mainKeyboardView != null) { + mainKeyboardView.setMainDictionaryAvailability(isMainDictionaryAvailable); + } + if (mHandler.hasPendingWaitForDictionaryLoad()) { + mHandler.cancelWaitForDictionaryLoad(); + mHandler.postResumeSuggestions(false /* shouldDelay */); + } + } + + void resetDictionaryFacilitatorIfNecessary() { + final Locale subtypeSwitcherLocale = mRichImm.getCurrentSubtypeLocale(); + final Locale subtypeLocale; + if (subtypeSwitcherLocale == null) { + // This happens in very rare corner cases - for example, immediately after a switch + // to LatinIME has been requested, about a frame later another switch happens. In this + // case, we are about to go down but we still don't know it, however the system tells + // us there is no current subtype. + Log.e(TAG, "System is reporting no current subtype."); + subtypeLocale = getResources().getConfiguration().locale; + } else { + subtypeLocale = subtypeSwitcherLocale; + } + if (mDictionaryFacilitator.isForLocale(subtypeLocale) + && mDictionaryFacilitator.isForAccount(mSettings.getCurrent().mAccount)) { + return; + } + resetDictionaryFacilitator(subtypeLocale); + } + + /** + * Reset the facilitator by loading dictionaries for the given locale and + * the current settings values. + * + * @param locale the locale + */ + // TODO: make sure the current settings always have the right locales, and read from them. + private void resetDictionaryFacilitator(final Locale locale) { + final SettingsValues settingsValues = mSettings.getCurrent(); + mDictionaryFacilitator.resetDictionaries(this /* context */, locale, + settingsValues.mUseContactsDict, settingsValues.mUsePersonalizedDicts, + false /* forceReloadMainDictionary */, + settingsValues.mAccount, "" /* dictNamePrefix */, + this /* DictionaryInitializationListener */); + if (settingsValues.mAutoCorrectionEnabledPerUserSettings) { + mInputLogic.mSuggest.setAutoCorrectionThreshold( + settingsValues.mAutoCorrectionThreshold); + } + mInputLogic.mSuggest.setPlausibilityThreshold(settingsValues.mPlausibilityThreshold); + } + + /** + * Reset suggest by loading the main dictionary of the current locale. + */ + /* package private */ void resetSuggestMainDict() { + final SettingsValues settingsValues = mSettings.getCurrent(); + mDictionaryFacilitator.resetDictionaries(this /* context */, + mDictionaryFacilitator.getLocale(), settingsValues.mUseContactsDict, + settingsValues.mUsePersonalizedDicts, + true /* forceReloadMainDictionary */, + settingsValues.mAccount, "" /* dictNamePrefix */, + this /* DictionaryInitializationListener */); + } + + @Override + public void onDestroy() { + mDictionaryFacilitator.closeDictionaries(); + mSettings.onDestroy(); + unregisterReceiver(mHideSoftInputReceiver); + unregisterReceiver(mRingerModeChangeReceiver); + unregisterReceiver(mDictionaryPackInstallReceiver); + unregisterReceiver(mDictionaryDumpBroadcastReceiver); + mStatsUtilsManager.onDestroy(this /* context */); + super.onDestroy(); + } + + @UsedForTesting + public void recycle() { + unregisterReceiver(mDictionaryPackInstallReceiver); + unregisterReceiver(mDictionaryDumpBroadcastReceiver); + unregisterReceiver(mRingerModeChangeReceiver); + mInputLogic.recycle(); + } + + private boolean isImeSuppressedByHardwareKeyboard() { + final KeyboardSwitcher switcher = KeyboardSwitcher.getInstance(); + return !onEvaluateInputViewShown() && switcher.isImeSuppressedByHardwareKeyboard( + mSettings.getCurrent(), switcher.getKeyboardSwitchState()); + } + + @Override + public void onConfigurationChanged(final Configuration conf) { + SettingsValues settingsValues = mSettings.getCurrent(); + if (settingsValues.mDisplayOrientation != conf.orientation) { + mHandler.startOrientationChanging(); + mInputLogic.onOrientationChange(mSettings.getCurrent()); + } + if (settingsValues.mHasHardwareKeyboard != Settings.readHasHardwareKeyboard(conf)) { + // If the state of having a hardware keyboard changed, then we want to reload the + // settings to adjust for that. + // TODO: we should probably do this unconditionally here, rather than only when we + // have a change in hardware keyboard configuration. + loadSettings(); + settingsValues = mSettings.getCurrent(); + if (isImeSuppressedByHardwareKeyboard()) { + // We call cleanupInternalStateForFinishInput() because it's the right thing to do; + // however, it seems at the moment the framework is passing us a seemingly valid + // but actually non-functional InputConnection object. So if this bug ever gets + // fixed we'll be able to remove the composition, but until it is this code is + // actually not doing much. + cleanupInternalStateForFinishInput(); + } + } + super.onConfigurationChanged(conf); + } + + @Override + public void onInitializeInterface() { + mDisplayContext = getDisplayContext(); + mKeyboardSwitcher.updateKeyboardTheme(mDisplayContext); + } + + /** + * Returns the context object whose resources are adjusted to match the metrics of the display. + * + * Note that before {@link android.os.Build.VERSION_CODES#KITKAT}, there is no way to support + * multi-display scenarios, so the context object will just return the IME context itself. + * + * With initiating multi-display APIs from {@link android.os.Build.VERSION_CODES#KITKAT}, the + * context object has to return with re-creating the display context according the metrics + * of the display in runtime. + * + * Starts from {@link android.os.Build.VERSION_CODES#S_V2}, the returning context object has + * became to IME context self since it ends up capable of updating its resources internally. + * + * @see android.content.Context#createDisplayContext(Display) + */ + private @NonNull Context getDisplayContext() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + // createDisplayContext is not available. + return this; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + // IME context sources is now managed by WindowProviderService from Android 12L. + return this; + } + // An issue in Q that non-activity components Resources / DisplayMetrics in + // Context doesn't well updated when the IME window moving to external display. + // Currently we do a workaround is to create new display context directly and re-init + // keyboard layout with this context. + final WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + return createDisplayContext(wm.getDefaultDisplay()); + } + + @Override + public View onCreateInputView() { + StatsUtils.onCreateInputView(); + return mKeyboardSwitcher.onCreateInputView(mDisplayContext, + mIsHardwareAcceleratedDrawingEnabled); + } + + @Override + public void setInputView(final View view) { + super.setInputView(view); + mInputView = view; + mInsetsUpdater = ViewOutlineProviderCompatUtils.setInsetsOutlineProvider(view); + updateSoftInputWindowLayoutParameters(); + mSuggestionStripView = (SuggestionStripView)view.findViewById(R.id.suggestion_strip_view); + if (hasSuggestionStripView()) { + mSuggestionStripView.setListener(this, view); + } + } + + @Override + public void setCandidatesView(final View view) { + // To ensure that CandidatesView will never be set. + } + + @Override + public void onStartInput(final EditorInfo editorInfo, final boolean restarting) { + mHandler.onStartInput(editorInfo, restarting); + } + + @Override + public void onStartInputView(final EditorInfo editorInfo, final boolean restarting) { + mHandler.onStartInputView(editorInfo, restarting); + mStatsUtilsManager.onStartInputView(); + } + + @Override + public void onFinishInputView(final boolean finishingInput) { + StatsUtils.onFinishInputView(); + mHandler.onFinishInputView(finishingInput); + mStatsUtilsManager.onFinishInputView(); + mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; + } + + @Override + public void onFinishInput() { + mHandler.onFinishInput(); + } + + @Override + public void onCurrentInputMethodSubtypeChanged(final InputMethodSubtype subtype) { + // Note that the calling sequence of onCreate() and onCurrentInputMethodSubtypeChanged() + // is not guaranteed. It may even be called at the same time on a different thread. + InputMethodSubtype oldSubtype = mRichImm.getCurrentSubtype().getRawSubtype(); + StatsUtils.onSubtypeChanged(oldSubtype, subtype); + mRichImm.onSubtypeChanged(subtype); + mInputLogic.onSubtypeChanged(SubtypeLocaleUtils.getCombiningRulesExtraValue(subtype), + mSettings.getCurrent()); + loadKeyboard(); + } + + void onStartInputInternal(final EditorInfo editorInfo, final boolean restarting) { + super.onStartInput(editorInfo, restarting); + + // If the primary hint language does not match the current subtype language, then try + // to switch to the primary hint language. + // TODO: Support all the locales in EditorInfo#hintLocales. + final Locale primaryHintLocale = EditorInfoCompatUtils.getPrimaryHintLocale(editorInfo); + if (primaryHintLocale == null) { + return; + } + final InputMethodSubtype newSubtype = mRichImm.findSubtypeByLocale(primaryHintLocale); + if (newSubtype == null || newSubtype.equals(mRichImm.getCurrentSubtype().getRawSubtype())) { + return; + } + mHandler.postSwitchLanguage(newSubtype); + } + + @SuppressWarnings("deprecation") + void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) { + super.onStartInputView(editorInfo, restarting); + + mDictionaryFacilitator.onStartInput(); + // Switch to the null consumer to handle cases leading to early exit below, for which we + // also wouldn't be consuming gesture data. + mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; + mRichImm.refreshSubtypeCaches(); + final KeyboardSwitcher switcher = mKeyboardSwitcher; + switcher.updateKeyboardTheme(mDisplayContext); + final MainKeyboardView mainKeyboardView = switcher.getMainKeyboardView(); + // If we are starting input in a different text field from before, we'll have to reload + // settings, so currentSettingsValues can't be final. + SettingsValues currentSettingsValues = mSettings.getCurrent(); + + if (editorInfo == null) { + Log.e(TAG, "Null EditorInfo in onStartInputView()"); + if (DebugFlags.DEBUG_ENABLED) { + throw new NullPointerException("Null EditorInfo in onStartInputView()"); + } + return; + } + if (DebugFlags.DEBUG_ENABLED) { + Log.d(TAG, "onStartInputView: editorInfo:" + + String.format("inputType=0x%08x imeOptions=0x%08x", + editorInfo.inputType, editorInfo.imeOptions)); + Log.d(TAG, "All caps = " + + ((editorInfo.inputType & InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS) != 0) + + ", sentence caps = " + + ((editorInfo.inputType & InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) != 0) + + ", word caps = " + + ((editorInfo.inputType & InputType.TYPE_TEXT_FLAG_CAP_WORDS) != 0)); + } + Log.i(TAG, "Starting input. Cursor position = " + + editorInfo.initialSelStart + "," + editorInfo.initialSelEnd); + // TODO: Consolidate these checks with {@link InputAttributes}. + if (InputAttributes.inPrivateImeOptions(null, NO_MICROPHONE_COMPAT, editorInfo)) { + Log.w(TAG, "Deprecated private IME option specified: " + editorInfo.privateImeOptions); + Log.w(TAG, "Use " + getPackageName() + "." + NO_MICROPHONE + " instead"); + } + if (InputAttributes.inPrivateImeOptions(getPackageName(), FORCE_ASCII, editorInfo)) { + Log.w(TAG, "Deprecated private IME option specified: " + editorInfo.privateImeOptions); + Log.w(TAG, "Use EditorInfo.IME_FLAG_FORCE_ASCII flag instead"); + } + + // In landscape mode, this method gets called without the input view being created. + if (mainKeyboardView == null) { + return; + } + + // Update to a gesture consumer with the current editor and IME state. + mGestureConsumer = GestureConsumer.newInstance(editorInfo, + mInputLogic.getPrivateCommandPerformer(), + mRichImm.getCurrentSubtypeLocale(), + switcher.getKeyboard()); + + // Forward this event to the accessibility utilities, if enabled. + final AccessibilityUtils accessUtils = AccessibilityUtils.getInstance(); + if (accessUtils.isTouchExplorationEnabled()) { + accessUtils.onStartInputViewInternal(mainKeyboardView, editorInfo, restarting); + } + + final boolean inputTypeChanged = !currentSettingsValues.isSameInputType(editorInfo); + final boolean isDifferentTextField = !restarting || inputTypeChanged; + + StatsUtils.onStartInputView(editorInfo.inputType, + Settings.getInstance().getCurrent().mDisplayOrientation, + !isDifferentTextField); + + // The EditorInfo might have a flag that affects fullscreen mode. + // Note: This call should be done by InputMethodService? + updateFullscreenMode(); + + // ALERT: settings have not been reloaded and there is a chance they may be stale. + // In the practice, if it is, we should have gotten onConfigurationChanged so it should + // be fine, but this is horribly confusing and must be fixed AS SOON AS POSSIBLE. + + // In some cases the input connection has not been reset yet and we can't access it. In + // this case we will need to call loadKeyboard() later, when it's accessible, so that we + // can go into the correct mode, so we need to do some housekeeping here. + final boolean needToCallLoadKeyboardLater; + final Suggest suggest = mInputLogic.mSuggest; + if (!isImeSuppressedByHardwareKeyboard()) { + // The app calling setText() has the effect of clearing the composing + // span, so we should reset our state unconditionally, even if restarting is true. + // We also tell the input logic about the combining rules for the current subtype, so + // it can adjust its combiners if needed. + mInputLogic.startInput(mRichImm.getCombiningRulesExtraValueOfCurrentSubtype(), + currentSettingsValues); + + resetDictionaryFacilitatorIfNecessary(); + + // TODO[IL]: Can the following be moved to InputLogic#startInput? + if (!mInputLogic.mConnection.resetCachesUponCursorMoveAndReturnSuccess( + editorInfo.initialSelStart, editorInfo.initialSelEnd, + false /* shouldFinishComposition */)) { + // Sometimes, while rotating, for some reason the framework tells the app we are not + // connected to it and that means we can't refresh the cache. In this case, schedule + // a refresh later. + // We try resetting the caches up to 5 times before giving up. + mHandler.postResetCaches(isDifferentTextField, 5 /* remainingTries */); + // mLastSelection{Start,End} are reset later in this method, no need to do it here + needToCallLoadKeyboardLater = true; + } else { + // When rotating, and when input is starting again in a field from where the focus + // didn't move (the keyboard having been closed with the back key), + // initialSelStart and initialSelEnd sometimes are lying. Make a best effort to + // work around this bug. + mInputLogic.mConnection.tryFixLyingCursorPosition(); + mHandler.postResumeSuggestionsForStartInput(true /* shouldDelay */); + needToCallLoadKeyboardLater = false; + } + } else { + // If we have a hardware keyboard we don't need to call loadKeyboard later anyway. + needToCallLoadKeyboardLater = false; + } + + if (isDifferentTextField || + !currentSettingsValues.hasSameOrientation(getResources().getConfiguration())) { + loadSettings(); + } + if (isDifferentTextField) { + mainKeyboardView.closing(); + currentSettingsValues = mSettings.getCurrent(); + + if (currentSettingsValues.mAutoCorrectionEnabledPerUserSettings) { + suggest.setAutoCorrectionThreshold( + currentSettingsValues.mAutoCorrectionThreshold); + } + suggest.setPlausibilityThreshold(currentSettingsValues.mPlausibilityThreshold); + + switcher.loadKeyboard(editorInfo, currentSettingsValues, getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + if (needToCallLoadKeyboardLater) { + // If we need to call loadKeyboard again later, we need to save its state now. The + // later call will be done in #retryResetCaches. + switcher.saveKeyboardState(); + } + } else if (restarting) { + // TODO: Come up with a more comprehensive way to reset the keyboard layout when + // a keyboard layout set doesn't get reloaded in this method. + switcher.resetKeyboardStateToAlphabet(getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + // In apps like Talk, we come here when the text is sent and the field gets emptied and + // we need to re-evaluate the shift state, but not the whole layout which would be + // disruptive. + // Space state must be updated before calling updateShiftState + switcher.requestUpdatingShiftState(getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + } + // This will set the punctuation suggestions if next word suggestion is off; + // otherwise it will clear the suggestion strip. + setNeutralSuggestionStrip(); + + mHandler.cancelUpdateSuggestionStrip(); + + mainKeyboardView.setMainDictionaryAvailability( + mDictionaryFacilitator.hasAtLeastOneInitializedMainDictionary()); + mainKeyboardView.setKeyPreviewPopupEnabled(currentSettingsValues.mKeyPreviewPopupOn, + currentSettingsValues.mKeyPreviewPopupDismissDelay); + mainKeyboardView.setSlidingKeyInputPreviewEnabled( + currentSettingsValues.mSlidingKeyInputPreviewEnabled); + mainKeyboardView.setGestureHandlingEnabledByUser( + currentSettingsValues.mGestureInputEnabled, + currentSettingsValues.mGestureTrailEnabled, + currentSettingsValues.mGestureFloatingPreviewTextEnabled); + + if (TRACE) Debug.startMethodTracing("/data/trace/latinime"); + } + + @Override + public void onWindowShown() { + super.onWindowShown(); + setNavigationBarVisibility(isInputViewShown()); + } + + @Override + public void onWindowHidden() { + super.onWindowHidden(); + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + if (mainKeyboardView != null) { + mainKeyboardView.closing(); + } + setNavigationBarVisibility(false); + } + + void onFinishInputInternal() { + super.onFinishInput(); + + mDictionaryFacilitator.onFinishInput(this); + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + if (mainKeyboardView != null) { + mainKeyboardView.closing(); + } + } + + void onFinishInputViewInternal(final boolean finishingInput) { + super.onFinishInputView(finishingInput); + cleanupInternalStateForFinishInput(); + } + + private void cleanupInternalStateForFinishInput() { + // Remove pending messages related to update suggestions + mHandler.cancelUpdateSuggestionStrip(); + // Should do the following in onFinishInputInternal but until JB MR2 it's not called :( + mInputLogic.finishInput(); + } + + protected void deallocateMemory() { + mKeyboardSwitcher.deallocateMemory(); + } + + @Override + public void onUpdateSelection(final int oldSelStart, final int oldSelEnd, + final int newSelStart, final int newSelEnd, + final int composingSpanStart, final int composingSpanEnd) { + super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, + composingSpanStart, composingSpanEnd); + if (DebugFlags.DEBUG_ENABLED) { + Log.i(TAG, "onUpdateSelection: oss=" + oldSelStart + ", ose=" + oldSelEnd + + ", nss=" + newSelStart + ", nse=" + newSelEnd + + ", cs=" + composingSpanStart + ", ce=" + composingSpanEnd); + } + + // This call happens whether our view is displayed or not, but if it's not then we should + // not attempt recorrection. This is true even with a hardware keyboard connected: if the + // view is not displayed we have no means of showing suggestions anyway, and if it is then + // we want to show suggestions anyway. + final SettingsValues settingsValues = mSettings.getCurrent(); + if (isInputViewShown() + && mInputLogic.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, + settingsValues)) { + mKeyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + } + } + + /** + * This is called when the user has clicked on the extracted text view, + * when running in fullscreen mode. The default implementation hides + * the suggestions view when this happens, but only if the extracted text + * editor has a vertical scroll bar because its text doesn't fit. + * Here we override the behavior due to the possibility that a re-correction could + * cause the suggestions strip to disappear and re-appear. + */ + @Override + public void onExtractedTextClicked() { + if (mSettings.getCurrent().needsToLookupSuggestions()) { + return; + } + + super.onExtractedTextClicked(); + } + + /** + * This is called when the user has performed a cursor movement in the + * extracted text view, when it is running in fullscreen mode. The default + * implementation hides the suggestions view when a vertical movement + * happens, but only if the extracted text editor has a vertical scroll bar + * because its text doesn't fit. + * Here we override the behavior due to the possibility that a re-correction could + * cause the suggestions strip to disappear and re-appear. + */ + @Override + public void onExtractedCursorMovement(final int dx, final int dy) { + if (mSettings.getCurrent().needsToLookupSuggestions()) { + return; + } + + super.onExtractedCursorMovement(dx, dy); + } + + @Override + public void hideWindow() { + mKeyboardSwitcher.onHideWindow(); + + if (TRACE) Debug.stopMethodTracing(); + if (isShowingOptionDialog()) { + mOptionsDialog.dismiss(); + mOptionsDialog = null; + } + super.hideWindow(); + } + + @Override + public void onDisplayCompletions(final CompletionInfo[] applicationSpecifiedCompletions) { + if (DebugFlags.DEBUG_ENABLED) { + Log.i(TAG, "Received completions:"); + if (applicationSpecifiedCompletions != null) { + for (int i = 0; i < applicationSpecifiedCompletions.length; i++) { + Log.i(TAG, " #" + i + ": " + applicationSpecifiedCompletions[i]); + } + } + } + if (!mSettings.getCurrent().isApplicationSpecifiedCompletionsOn()) { + return; + } + // If we have an update request in flight, we need to cancel it so it does not override + // these completions. + mHandler.cancelUpdateSuggestionStrip(); + if (applicationSpecifiedCompletions == null) { + setNeutralSuggestionStrip(); + return; + } + + final ArrayList<SuggestedWords.SuggestedWordInfo> applicationSuggestedWords = + SuggestedWords.getFromApplicationSpecifiedCompletions( + applicationSpecifiedCompletions); + final SuggestedWords suggestedWords = new SuggestedWords(applicationSuggestedWords, + null /* rawSuggestions */, + null /* typedWord */, + false /* typedWordValid */, + false /* willAutoCorrect */, + false /* isObsoleteSuggestions */, + SuggestedWords.INPUT_STYLE_APPLICATION_SPECIFIED /* inputStyle */, + SuggestedWords.NOT_A_SEQUENCE_NUMBER); + // When in fullscreen mode, show completions generated by the application forcibly + setSuggestedWords(suggestedWords); + } + + @Override + public void onComputeInsets(final InputMethodService.Insets outInsets) { + super.onComputeInsets(outInsets); + // This method may be called before {@link #setInputView(View)}. + if (mInputView == null) { + return; + } + final SettingsValues settingsValues = mSettings.getCurrent(); + final View visibleKeyboardView = mKeyboardSwitcher.getVisibleKeyboardView(); + if (visibleKeyboardView == null || !hasSuggestionStripView()) { + return; + } + final int inputHeight = mInputView.getHeight(); + if (isImeSuppressedByHardwareKeyboard() && !visibleKeyboardView.isShown()) { + // If there is a hardware keyboard and a visible software keyboard view has been hidden, + // no visual element will be shown on the screen. + outInsets.contentTopInsets = inputHeight; + outInsets.visibleTopInsets = inputHeight; + mInsetsUpdater.setInsets(outInsets); + return; + } + final int suggestionsHeight = (!mKeyboardSwitcher.isShowingEmojiPalettes() + && mSuggestionStripView.getVisibility() == View.VISIBLE) + ? mSuggestionStripView.getHeight() : 0; + final int visibleTopY = inputHeight - visibleKeyboardView.getHeight() - suggestionsHeight; + mSuggestionStripView.setMoreSuggestionsHeight(visibleTopY); + // Need to set expanded touchable region only if a keyboard view is being shown. + if (visibleKeyboardView.isShown()) { + final int touchLeft = 0; + final int touchTop = mKeyboardSwitcher.isShowingMoreKeysPanel() ? 0 : visibleTopY; + final int touchRight = visibleKeyboardView.getWidth(); + final int touchBottom = inputHeight; + outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_REGION; + outInsets.touchableRegion.set(touchLeft, touchTop, touchRight, touchBottom); + } + outInsets.contentTopInsets = visibleTopY; + outInsets.visibleTopInsets = visibleTopY; + mInsetsUpdater.setInsets(outInsets); + } + + public void startShowingInputView(final boolean needsToLoadKeyboard) { + mIsExecutingStartShowingInputView = true; + // This {@link #showWindow(boolean)} will eventually call back + // {@link #onEvaluateInputViewShown()}. + showWindow(true /* showInput */); + mIsExecutingStartShowingInputView = false; + if (needsToLoadKeyboard) { + loadKeyboard(); + } + } + + public void stopShowingInputView() { + showWindow(false /* showInput */); + } + + @Override + public boolean onShowInputRequested(final int flags, final boolean configChange) { + if (isImeSuppressedByHardwareKeyboard()) { + return true; + } + return super.onShowInputRequested(flags, configChange); + } + + @Override + public boolean onEvaluateInputViewShown() { + if (mIsExecutingStartShowingInputView) { + return true; + } + return super.onEvaluateInputViewShown(); + } + + @Override + public boolean onEvaluateFullscreenMode() { + final SettingsValues settingsValues = mSettings.getCurrent(); + if (isImeSuppressedByHardwareKeyboard()) { + // If there is a hardware keyboard, disable full screen mode. + return false; + } + // Reread resource value here, because this method is called by the framework as needed. + final boolean isFullscreenModeAllowed = Settings.readUseFullscreenMode(getResources()); + if (super.onEvaluateFullscreenMode() && isFullscreenModeAllowed) { + // TODO: Remove this hack. Actually we should not really assume NO_EXTRACT_UI + // implies NO_FULLSCREEN. However, the framework mistakenly does. i.e. NO_EXTRACT_UI + // without NO_FULLSCREEN doesn't work as expected. Because of this we need this + // hack for now. Let's get rid of this once the framework gets fixed. + final EditorInfo ei = getCurrentInputEditorInfo(); + return !(ei != null && ((ei.imeOptions & EditorInfo.IME_FLAG_NO_EXTRACT_UI) != 0)); + } + return false; + } + + @Override + public void updateFullscreenMode() { + super.updateFullscreenMode(); + updateSoftInputWindowLayoutParameters(); + } + + private void updateSoftInputWindowLayoutParameters() { + // Override layout parameters to expand {@link SoftInputWindow} to the entire screen. + // See {@link InputMethodService#setinputView(View)} and + // {@link SoftInputWindow#updateWidthHeight(WindowManager.LayoutParams)}. + final Window window = getWindow().getWindow(); + ViewLayoutUtils.updateLayoutHeightOf(window, LayoutParams.MATCH_PARENT); + // This method may be called before {@link #setInputView(View)}. + if (mInputView != null) { + // In non-fullscreen mode, {@link InputView} and its parent inputArea should expand to + // the entire screen and be placed at the bottom of {@link SoftInputWindow}. + // In fullscreen mode, these shouldn't expand to the entire screen and should be + // coexistent with {@link #mExtractedArea} above. + // See {@link InputMethodService#setInputView(View) and + // com.android.internal.R.layout.input_method.xml. + final int layoutHeight = isFullscreenMode() + ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT; + final View inputArea = window.findViewById(android.R.id.inputArea); + ViewLayoutUtils.updateLayoutHeightOf(inputArea, layoutHeight); + ViewLayoutUtils.updateLayoutGravityOf(inputArea, Gravity.BOTTOM); + ViewLayoutUtils.updateLayoutHeightOf(mInputView, layoutHeight); + } + } + + int getCurrentAutoCapsState() { + return mInputLogic.getCurrentAutoCapsState(mSettings.getCurrent()); + } + + int getCurrentRecapitalizeState() { + return mInputLogic.getCurrentRecapitalizeState(); + } + + /** + * @param codePoints code points to get coordinates for. + * @return x,y coordinates for this keyboard, as a flattened array. + */ + public int[] getCoordinatesForCurrentKeyboard(final int[] codePoints) { + final Keyboard keyboard = mKeyboardSwitcher.getKeyboard(); + if (null == keyboard) { + return CoordinateUtils.newCoordinateArray(codePoints.length, + Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE); + } + return keyboard.getCoordinates(codePoints); + } + + // Callback for the {@link SuggestionStripView}, to call when the important notice strip is + // pressed. + @Override + public void showImportantNoticeContents() { + PermissionsManager.get(this).requestPermissions( + this /* PermissionsResultCallback */, + null /* activity */, permission.READ_CONTACTS); + } + + @Override + public void onRequestPermissionsResult(boolean allGranted) { + ImportantNoticeUtils.updateContactsNoticeShown(this /* context */); + setNeutralSuggestionStrip(); + } + + public void displaySettingsDialog() { + if (isShowingOptionDialog()) { + return; + } + showSubtypeSelectorAndSettings(); + } + + @Override + public boolean onCustomRequest(final int requestCode) { + if (isShowingOptionDialog()) return false; + switch (requestCode) { + case Constants.CUSTOM_CODE_SHOW_INPUT_METHOD_PICKER: + if (mRichImm.hasMultipleEnabledIMEsOrSubtypes(true /* include aux subtypes */)) { + mRichImm.getInputMethodManager().showInputMethodPicker(); + return true; + } + return false; + } + return false; + } + + private boolean isShowingOptionDialog() { + return mOptionsDialog != null && mOptionsDialog.isShowing(); + } + + public void switchLanguage(final InputMethodSubtype subtype) { + final IBinder token = getWindow().getWindow().getAttributes().token; + mRichImm.setInputMethodAndSubtype(token, subtype); + } + + // TODO: Revise the language switch key behavior to make it much smarter and more reasonable. + public void switchToNextSubtype() { + final IBinder token = getWindow().getWindow().getAttributes().token; + if (shouldSwitchToOtherInputMethods()) { + mRichImm.switchToNextInputMethod(token, false /* onlyCurrentIme */); + return; + } + mSubtypeState.switchSubtype(token, mRichImm); + } + + // TODO: Instead of checking for alphabetic keyboard here, separate keycodes for + // alphabetic shift and shift while in symbol layout and get rid of this method. + private int getCodePointForKeyboard(final int codePoint) { + if (Constants.CODE_SHIFT == codePoint) { + final Keyboard currentKeyboard = mKeyboardSwitcher.getKeyboard(); + if (null != currentKeyboard && currentKeyboard.mId.isAlphabetKeyboard()) { + return codePoint; + } + return Constants.CODE_SYMBOL_SHIFT; + } + return codePoint; + } + + // Implementation of {@link KeyboardActionListener}. + @Override + public void onCodeInput(final int codePoint, final int x, final int y, + final boolean isKeyRepeat) { + // TODO: this processing does not belong inside LatinIME, the caller should be doing this. + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + // x and y include some padding, but everything down the line (especially native + // code) needs the coordinates in the keyboard frame. + // TODO: We should reconsider which coordinate system should be used to represent + // keyboard event. Also we should pull this up -- LatinIME has no business doing + // this transformation, it should be done already before calling onEvent. + final int keyX = mainKeyboardView.getKeyX(x); + final int keyY = mainKeyboardView.getKeyY(y); + final Event event = createSoftwareKeypressEvent(getCodePointForKeyboard(codePoint), + keyX, keyY, isKeyRepeat); + onEvent(event); + } + + // This method is public for testability of LatinIME, but also in the future it should + // completely replace #onCodeInput. + public void onEvent(@Nonnull final Event event) { + if (Constants.CODE_SHORTCUT == event.mKeyCode) { + mRichImm.switchToShortcutIme(this); + } + final InputTransaction completeInputTransaction = + mInputLogic.onCodeInput(mSettings.getCurrent(), event, + mKeyboardSwitcher.getKeyboardShiftMode(), + mKeyboardSwitcher.getCurrentKeyboardScriptId(), mHandler); + updateStateAfterInputTransaction(completeInputTransaction); + mKeyboardSwitcher.onEvent(event, getCurrentAutoCapsState(), getCurrentRecapitalizeState()); + } + + // A helper method to split the code point and the key code. Ultimately, they should not be + // squashed into the same variable, and this method should be removed. + // public for testing, as we don't want to copy the same logic into test code + @Nonnull + public static Event createSoftwareKeypressEvent(final int keyCodeOrCodePoint, final int keyX, + final int keyY, final boolean isKeyRepeat) { + final int keyCode; + final int codePoint; + if (keyCodeOrCodePoint <= 0) { + keyCode = keyCodeOrCodePoint; + codePoint = Event.NOT_A_CODE_POINT; + } else { + keyCode = Event.NOT_A_KEY_CODE; + codePoint = keyCodeOrCodePoint; + } + return Event.createSoftwareKeypressEvent(codePoint, keyCode, keyX, keyY, isKeyRepeat); + } + + // Called from PointerTracker through the KeyboardActionListener interface + @Override + public void onTextInput(final String rawText) { + // TODO: have the keyboard pass the correct key code when we need it. + final Event event = Event.createSoftwareTextEvent(rawText, Constants.CODE_OUTPUT_TEXT); + final InputTransaction completeInputTransaction = + mInputLogic.onTextInput(mSettings.getCurrent(), event, + mKeyboardSwitcher.getKeyboardShiftMode(), mHandler); + updateStateAfterInputTransaction(completeInputTransaction); + mKeyboardSwitcher.onEvent(event, getCurrentAutoCapsState(), getCurrentRecapitalizeState()); + } + + @Override + public void onStartBatchInput() { + mInputLogic.onStartBatchInput(mSettings.getCurrent(), mKeyboardSwitcher, mHandler); + mGestureConsumer.onGestureStarted( + mRichImm.getCurrentSubtypeLocale(), + mKeyboardSwitcher.getKeyboard()); + } + + @Override + public void onUpdateBatchInput(final InputPointers batchPointers) { + mInputLogic.onUpdateBatchInput(batchPointers); + } + + @Override + public void onEndBatchInput(final InputPointers batchPointers) { + mInputLogic.onEndBatchInput(batchPointers); + mGestureConsumer.onGestureCompleted(batchPointers); + } + + @Override + public void onCancelBatchInput() { + mInputLogic.onCancelBatchInput(mHandler); + mGestureConsumer.onGestureCanceled(); + } + + /** + * To be called after the InputLogic has gotten a chance to act on the suggested words by the + * IME for the full gesture, possibly updating the TextView to reflect the first suggestion. + * <p> + * This method must be run on the UI Thread. + * @param suggestedWords suggested words by the IME for the full gesture. + */ + public void onTailBatchInputResultShown(final SuggestedWords suggestedWords) { + mGestureConsumer.onImeSuggestionsProcessed(suggestedWords, + mInputLogic.getComposingStart(), mInputLogic.getComposingLength(), + mDictionaryFacilitator); + } + + // This method must run on the UI Thread. + void showGesturePreviewAndSuggestionStrip(@Nonnull final SuggestedWords suggestedWords, + final boolean dismissGestureFloatingPreviewText) { + showSuggestionStrip(suggestedWords); + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + mainKeyboardView.showGestureFloatingPreviewText(suggestedWords, + dismissGestureFloatingPreviewText /* dismissDelayed */); + } + + // Called from PointerTracker through the KeyboardActionListener interface + @Override + public void onFinishSlidingInput() { + // User finished sliding input. + mKeyboardSwitcher.onFinishSlidingInput(getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + } + + // Called from PointerTracker through the KeyboardActionListener interface + @Override + public void onCancelInput() { + // User released a finger outside any key + // Nothing to do so far. + } + + public boolean hasSuggestionStripView() { + return null != mSuggestionStripView; + } + + private void setSuggestedWords(final SuggestedWords suggestedWords) { + final SettingsValues currentSettingsValues = mSettings.getCurrent(); + mInputLogic.setSuggestedWords(suggestedWords); + // TODO: Modify this when we support suggestions with hard keyboard + if (!hasSuggestionStripView()) { + return; + } + if (!onEvaluateInputViewShown()) { + return; + } + + final boolean shouldShowImportantNotice = + ImportantNoticeUtils.shouldShowImportantNotice(this, currentSettingsValues); + final boolean shouldShowSuggestionCandidates = + currentSettingsValues.mInputAttributes.mShouldShowSuggestions + && currentSettingsValues.isSuggestionsEnabledPerUserSettings(); + final boolean shouldShowSuggestionsStripUnlessPassword = shouldShowImportantNotice + || currentSettingsValues.mShowsVoiceInputKey + || shouldShowSuggestionCandidates + || currentSettingsValues.isApplicationSpecifiedCompletionsOn(); + final boolean shouldShowSuggestionsStrip = shouldShowSuggestionsStripUnlessPassword + && !currentSettingsValues.mInputAttributes.mIsPasswordField; + mSuggestionStripView.updateVisibility(shouldShowSuggestionsStrip, isFullscreenMode()); + if (!shouldShowSuggestionsStrip) { + return; + } + + final boolean isEmptyApplicationSpecifiedCompletions = + currentSettingsValues.isApplicationSpecifiedCompletionsOn() + && suggestedWords.isEmpty(); + final boolean noSuggestionsFromDictionaries = suggestedWords.isEmpty() + || suggestedWords.isPunctuationSuggestions() + || isEmptyApplicationSpecifiedCompletions; + final boolean isBeginningOfSentencePrediction = (suggestedWords.mInputStyle + == SuggestedWords.INPUT_STYLE_BEGINNING_OF_SENTENCE_PREDICTION); + final boolean noSuggestionsToOverrideImportantNotice = noSuggestionsFromDictionaries + || isBeginningOfSentencePrediction; + if (shouldShowImportantNotice && noSuggestionsToOverrideImportantNotice) { + if (mSuggestionStripView.maybeShowImportantNoticeTitle()) { + return; + } + } + + if (currentSettingsValues.isSuggestionsEnabledPerUserSettings() + || currentSettingsValues.isApplicationSpecifiedCompletionsOn() + // We should clear the contextual strip if there is no suggestion from dictionaries. + || noSuggestionsFromDictionaries) { + mSuggestionStripView.setSuggestions(suggestedWords, + mRichImm.getCurrentSubtype().isRtlSubtype()); + } + } + + // TODO[IL]: Move this out of LatinIME. + public void getSuggestedWords(final int inputStyle, final int sequenceNumber, + final OnGetSuggestedWordsCallback callback) { + final Keyboard keyboard = mKeyboardSwitcher.getKeyboard(); + if (keyboard == null) { + callback.onGetSuggestedWords(SuggestedWords.getEmptyInstance()); + return; + } + mInputLogic.getSuggestedWords(mSettings.getCurrent(), keyboard, + mKeyboardSwitcher.getKeyboardShiftMode(), inputStyle, sequenceNumber, callback); + } + + @Override + public void showSuggestionStrip(final SuggestedWords suggestedWords) { + if (suggestedWords.isEmpty()) { + setNeutralSuggestionStrip(); + } else { + setSuggestedWords(suggestedWords); + } + // Cache the auto-correction in accessibility code so we can speak it if the user + // touches a key that will insert it. + AccessibilityUtils.getInstance().setAutoCorrection(suggestedWords); + } + + // Called from {@link SuggestionStripView} through the {@link SuggestionStripView#Listener} + // interface + @Override + public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { + final InputTransaction completeInputTransaction = mInputLogic.onPickSuggestionManually( + mSettings.getCurrent(), suggestionInfo, + mKeyboardSwitcher.getKeyboardShiftMode(), + mKeyboardSwitcher.getCurrentKeyboardScriptId(), + mHandler); + updateStateAfterInputTransaction(completeInputTransaction); + } + + // This will show either an empty suggestion strip (if prediction is enabled) or + // punctuation suggestions (if it's disabled). + @Override + public void setNeutralSuggestionStrip() { + final SettingsValues currentSettings = mSettings.getCurrent(); + final SuggestedWords neutralSuggestions = currentSettings.mBigramPredictionEnabled + ? SuggestedWords.getEmptyInstance() + : currentSettings.mSpacingAndPunctuations.mSuggestPuncList; + setSuggestedWords(neutralSuggestions); + } + + // Outside LatinIME, only used by the {@link InputTestsBase} test suite. + @UsedForTesting + void loadKeyboard() { + // Since we are switching languages, the most urgent thing is to let the keyboard graphics + // update. LoadKeyboard does that, but we need to wait for buffer flip for it to be on + // the screen. Anything we do right now will delay this, so wait until the next frame + // before we do the rest, like reopening dictionaries and updating suggestions. So we + // post a message. + mHandler.postReopenDictionaries(); + loadSettings(); + if (mKeyboardSwitcher.getMainKeyboardView() != null) { + // Reload keyboard because the current language has been changed. + mKeyboardSwitcher.loadKeyboard(getCurrentInputEditorInfo(), mSettings.getCurrent(), + getCurrentAutoCapsState(), getCurrentRecapitalizeState()); + } + } + + /** + * After an input transaction has been executed, some state must be updated. This includes + * the shift state of the keyboard and suggestions. This method looks at the finished + * inputTransaction to find out what is necessary and updates the state accordingly. + * @param inputTransaction The transaction that has been executed. + */ + private void updateStateAfterInputTransaction(final InputTransaction inputTransaction) { + switch (inputTransaction.getRequiredShiftUpdate()) { + case InputTransaction.SHIFT_UPDATE_LATER: + mHandler.postUpdateShiftState(); + break; + case InputTransaction.SHIFT_UPDATE_NOW: + mKeyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + break; + default: // SHIFT_NO_UPDATE + } + if (inputTransaction.requiresUpdateSuggestions()) { + final int inputStyle; + if (inputTransaction.mEvent.isSuggestionStripPress()) { + // Suggestion strip press: no input. + inputStyle = SuggestedWords.INPUT_STYLE_NONE; + } else if (inputTransaction.mEvent.isGesture()) { + inputStyle = SuggestedWords.INPUT_STYLE_TAIL_BATCH; + } else { + inputStyle = SuggestedWords.INPUT_STYLE_TYPING; + } + mHandler.postUpdateSuggestionStrip(inputStyle); + } + if (inputTransaction.didAffectContents()) { + mSubtypeState.setCurrentSubtypeHasBeenUsed(); + } + } + + private void hapticAndAudioFeedback(final int code, final int repeatCount) { + final MainKeyboardView keyboardView = mKeyboardSwitcher.getMainKeyboardView(); + if (keyboardView != null && keyboardView.isInDraggingFinger()) { + // No need to feedback while finger is dragging. + return; + } + if (repeatCount > 0) { + if (code == Constants.CODE_DELETE && !mInputLogic.mConnection.canDeleteCharacters()) { + // No need to feedback when repeat delete key will have no effect. + return; + } + // TODO: Use event time that the last feedback has been generated instead of relying on + // a repeat count to thin out feedback. + if (repeatCount % PERIOD_FOR_AUDIO_AND_HAPTIC_FEEDBACK_IN_KEY_REPEAT == 0) { + return; + } + } + final AudioAndHapticFeedbackManager feedbackManager = + AudioAndHapticFeedbackManager.getInstance(); + if (repeatCount == 0) { + // TODO: Reconsider how to perform haptic feedback when repeating key. + feedbackManager.performHapticFeedback(keyboardView); + } + feedbackManager.performAudioFeedback(code); + } + + // Callback of the {@link KeyboardActionListener}. This is called when a key is depressed; + // release matching call is {@link #onReleaseKey(int,boolean)} below. + @Override + public void onPressKey(final int primaryCode, final int repeatCount, + final boolean isSinglePointer) { + mKeyboardSwitcher.onPressKey(primaryCode, isSinglePointer, getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + hapticAndAudioFeedback(primaryCode, repeatCount); + } + + // Callback of the {@link KeyboardActionListener}. This is called when a key is released; + // press matching call is {@link #onPressKey(int,int,boolean)} above. + @Override + public void onReleaseKey(final int primaryCode, final boolean withSliding) { + mKeyboardSwitcher.onReleaseKey(primaryCode, withSliding, getCurrentAutoCapsState(), + getCurrentRecapitalizeState()); + } + + private HardwareEventDecoder getHardwareKeyEventDecoder(final int deviceId) { + final HardwareEventDecoder decoder = mHardwareEventDecoders.get(deviceId); + if (null != decoder) return decoder; + // TODO: create the decoder according to the specification + final HardwareEventDecoder newDecoder = new HardwareKeyboardEventDecoder(deviceId); + mHardwareEventDecoders.put(deviceId, newDecoder); + return newDecoder; + } + + // Hooks for hardware keyboard + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent keyEvent) { + if (mEmojiAltPhysicalKeyDetector == null) { + mEmojiAltPhysicalKeyDetector = new EmojiAltPhysicalKeyDetector( + getApplicationContext().getResources()); + } + mEmojiAltPhysicalKeyDetector.onKeyDown(keyEvent); + if (!ProductionFlags.IS_HARDWARE_KEYBOARD_SUPPORTED) { + return super.onKeyDown(keyCode, keyEvent); + } + final Event event = getHardwareKeyEventDecoder( + keyEvent.getDeviceId()).decodeHardwareKey(keyEvent); + // If the event is not handled by LatinIME, we just pass it to the parent implementation. + // If it's handled, we return true because we did handle it. + if (event.isHandled()) { + mInputLogic.onCodeInput(mSettings.getCurrent(), event, + mKeyboardSwitcher.getKeyboardShiftMode(), + // TODO: this is not necessarily correct for a hardware keyboard right now + mKeyboardSwitcher.getCurrentKeyboardScriptId(), + mHandler); + return true; + } + return super.onKeyDown(keyCode, keyEvent); + } + + @Override + public boolean onKeyUp(final int keyCode, final KeyEvent keyEvent) { + if (mEmojiAltPhysicalKeyDetector == null) { + mEmojiAltPhysicalKeyDetector = new EmojiAltPhysicalKeyDetector( + getApplicationContext().getResources()); + } + mEmojiAltPhysicalKeyDetector.onKeyUp(keyEvent); + if (!ProductionFlags.IS_HARDWARE_KEYBOARD_SUPPORTED) { + return super.onKeyUp(keyCode, keyEvent); + } + final long keyIdentifier = keyEvent.getDeviceId() << 32 + keyEvent.getKeyCode(); + if (mInputLogic.mCurrentlyPressedHardwareKeys.remove(keyIdentifier)) { + return true; + } + return super.onKeyUp(keyCode, keyEvent); + } + + // onKeyDown and onKeyUp are the main events we are interested in. There are two more events + // related to handling of hardware key events that we may want to implement in the future: + // boolean onKeyLongPress(final int keyCode, final KeyEvent event); + // boolean onKeyMultiple(final int keyCode, final int count, final KeyEvent event); + + // receive ringer mode change. + private final BroadcastReceiver mRingerModeChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (action.equals(AudioManager.RINGER_MODE_CHANGED_ACTION)) { + AudioAndHapticFeedbackManager.getInstance().onRingerModeChanged(); + } + } + }; + + /** + * Starts {@link android.app.Activity} on the same display where the IME is shown. + * + * @param intent {@link Intent} to be used to start {@link android.app.Activity}. + */ + private void startActivityOnTheSameDisplay(Intent intent) { + // Note that WindowManager#getDefaultDisplay() returns the display ID associated with the + // Context from which the WindowManager instance was obtained. Therefore the following code + // returns the display ID for the window where the IME is shown. + final int currentDisplayId = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)) + .getDefaultDisplay().getDisplayId(); + + startActivity(intent, + ActivityOptions.makeBasic().setLaunchDisplayId(currentDisplayId).toBundle()); + } + + void launchSettings(final String extraEntryValue) { + mInputLogic.commitTyped(mSettings.getCurrent(), LastComposedWord.NOT_A_SEPARATOR); + requestHideSelf(0); + final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); + if (mainKeyboardView != null) { + mainKeyboardView.closing(); + } + final Intent intent = new Intent(); + intent.setClass(LatinIME.this, SettingsActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(SettingsActivity.EXTRA_SHOW_HOME_AS_UP, false); + intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY, extraEntryValue); + startActivityOnTheSameDisplay(intent); + } + + private void showSubtypeSelectorAndSettings() { + final CharSequence title = getString(R.string.english_ime_input_options); + // TODO: Should use new string "Select active input modes". + final CharSequence languageSelectionTitle = getString(R.string.language_selection_title); + final CharSequence[] items = new CharSequence[] { + languageSelectionTitle, + getString(ApplicationUtils.getActivityTitleResId(this, SettingsActivity.class)) + }; + final String imeId = mRichImm.getInputMethodIdOfThisIme(); + final OnClickListener listener = new OnClickListener() { + @Override + public void onClick(DialogInterface di, int position) { + di.dismiss(); + switch (position) { + case 0: + final Intent intent = IntentUtils.getInputLanguageSelectionIntent( + imeId, + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(Intent.EXTRA_TITLE, languageSelectionTitle); + startActivityOnTheSameDisplay(intent); + break; + case 1: + launchSettings(SettingsActivity.EXTRA_ENTRY_VALUE_LONG_PRESS_COMMA); + break; + } + } + }; + final AlertDialog.Builder builder = new AlertDialog.Builder( + DialogUtils.getPlatformDialogThemeContext(this)); + builder.setItems(items, listener).setTitle(title); + final AlertDialog dialog = builder.create(); + dialog.setCancelable(true /* cancelable */); + dialog.setCanceledOnTouchOutside(true /* cancelable */); + showOptionDialog(dialog); + } + + // TODO: Move this method out of {@link LatinIME}. + private void showOptionDialog(final AlertDialog dialog) { + final IBinder windowToken = mKeyboardSwitcher.getMainKeyboardView().getWindowToken(); + if (windowToken == null) { + return; + } + + final Window window = dialog.getWindow(); + final WindowManager.LayoutParams lp = window.getAttributes(); + lp.token = windowToken; + lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG; + window.setAttributes(lp); + window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + + mOptionsDialog = dialog; + dialog.show(); + } + + @UsedForTesting + SuggestedWords getSuggestedWordsForTest() { + // You may not use this method for anything else than debug + return DebugFlags.DEBUG_ENABLED ? mInputLogic.mSuggestedWords : null; + } + + // DO NOT USE THIS for any other purpose than testing. This is information private to LatinIME. + @UsedForTesting + void waitForLoadingDictionaries(final long timeout, final TimeUnit unit) + throws InterruptedException { + mDictionaryFacilitator.waitForLoadingDictionariesForTesting(timeout, unit); + } + + // DO NOT USE THIS for any other purpose than testing. This can break the keyboard badly. + @UsedForTesting + void replaceDictionariesForTest(final Locale locale) { + final SettingsValues settingsValues = mSettings.getCurrent(); + mDictionaryFacilitator.resetDictionaries(this, locale, + settingsValues.mUseContactsDict, settingsValues.mUsePersonalizedDicts, + false /* forceReloadMainDictionary */, + settingsValues.mAccount, "", /* dictionaryNamePrefix */ + this /* DictionaryInitializationListener */); + } + + // DO NOT USE THIS for any other purpose than testing. + @UsedForTesting + void clearPersonalizedDictionariesForTest() { + mDictionaryFacilitator.clearUserHistoryDictionary(this); + } + + @UsedForTesting + List<InputMethodSubtype> getEnabledSubtypesForTest() { + return (mRichImm != null) ? mRichImm.getMyEnabledInputMethodSubtypeList( + true /* allowsImplicitlySelectedSubtypes */) : new ArrayList<InputMethodSubtype>(); + } + + public void dumpDictionaryForDebug(final String dictName) { + if (!mDictionaryFacilitator.isActive()) { + resetDictionaryFacilitatorIfNecessary(); + } + mDictionaryFacilitator.dumpDictionaryForDebug(dictName); + } + + public void debugDumpStateAndCrashWithException(final String context) { + final SettingsValues settingsValues = mSettings.getCurrent(); + final StringBuilder s = new StringBuilder(settingsValues.toString()); + s.append("\nAttributes : ").append(settingsValues.mInputAttributes) + .append("\nContext : ").append(context); + throw new RuntimeException(s.toString()); + } + + @Override + protected void dump(final FileDescriptor fd, final PrintWriter fout, final String[] args) { + super.dump(fd, fout, args); + + final Printer p = new PrintWriterPrinter(fout); + p.println("LatinIME state :"); + p.println(" VersionCode = " + ApplicationUtils.getVersionCode(this)); + p.println(" VersionName = " + ApplicationUtils.getVersionName(this)); + final Keyboard keyboard = mKeyboardSwitcher.getKeyboard(); + final int keyboardMode = keyboard != null ? keyboard.mId.mMode : -1; + p.println(" Keyboard mode = " + keyboardMode); + final SettingsValues settingsValues = mSettings.getCurrent(); + p.println(settingsValues.dump()); + p.println(mDictionaryFacilitator.dump(this /* context */)); + // TODO: Dump all settings values + } + + public boolean shouldSwitchToOtherInputMethods() { + // TODO: Revisit here to reorganize the settings. Probably we can/should use different + // strategy once the implementation of + // {@link InputMethodManager#shouldOfferSwitchingToNextInputMethod} is defined well. + final boolean fallbackValue = mSettings.getCurrent().mIncludesOtherImesInLanguageSwitchList; + final IBinder token = getWindow().getWindow().getAttributes().token; + if (token == null) { + return fallbackValue; + } + return mRichImm.shouldOfferSwitchingToNextInputMethod(token, fallbackValue); + } + + public boolean shouldShowLanguageSwitchKey() { + // TODO: Revisit here to reorganize the settings. Probably we can/should use different + // strategy once the implementation of + // {@link InputMethodManager#shouldOfferSwitchingToNextInputMethod} is defined well. + final boolean fallbackValue = mSettings.getCurrent().isLanguageSwitchKeyEnabled(); + final IBinder token = getWindow().getWindow().getAttributes().token; + if (token == null) { + return fallbackValue; + } + return mRichImm.shouldOfferSwitchingToNextInputMethod(token, fallbackValue); + } + + private void setNavigationBarVisibility(final boolean visible) { + if (BuildCompatUtils.EFFECTIVE_SDK_INT > Build.VERSION_CODES.M) { + // For N and later, IMEs can specify Color.TRANSPARENT to make the navigation bar + // transparent. For other colors the system uses the default color. + getWindow().getWindow().setNavigationBarColor( + visible ? Color.BLACK : Color.TRANSPARENT); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/NgramContext.java b/java/src/org/kelar/inputmethod/latin/NgramContext.java new file mode 100644 index 000000000..3555ab9a2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/NgramContext.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import android.text.TextUtils; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; + +import java.util.ArrayList; +import java.util.Arrays; + +import javax.annotation.Nonnull; + +/** + * Class to represent information of previous words. This class is used to add n-gram entries + * into binary dictionaries, to get predictions, and to get suggestions. + */ +public class NgramContext { + @Nonnull + public static final NgramContext EMPTY_PREV_WORDS_INFO = + new NgramContext(WordInfo.EMPTY_WORD_INFO); + @Nonnull + public static final NgramContext BEGINNING_OF_SENTENCE = + new NgramContext(WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO); + + public static final String BEGINNING_OF_SENTENCE_TAG = "<S>"; + + public static final String CONTEXT_SEPARATOR = " "; + + public static NgramContext getEmptyPrevWordsContext(int maxPrevWordCount) { + return new NgramContext(maxPrevWordCount, WordInfo.EMPTY_WORD_INFO); + } + + /** + * Word information used to represent previous words information. + */ + public static class WordInfo { + @Nonnull + public static final WordInfo EMPTY_WORD_INFO = new WordInfo(null); + @Nonnull + public static final WordInfo BEGINNING_OF_SENTENCE_WORD_INFO = new WordInfo(); + + // This is an empty char sequence when mIsBeginningOfSentence is true. + public final CharSequence mWord; + // TODO: Have sentence separator. + // Whether the current context is beginning of sentence or not. This is true when composing + // at the beginning of an input field or composing a word after a sentence separator. + public final boolean mIsBeginningOfSentence; + + // Beginning of sentence. + private WordInfo() { + mWord = ""; + mIsBeginningOfSentence = true; + } + + public WordInfo(final CharSequence word) { + mWord = word; + mIsBeginningOfSentence = false; + } + + public boolean isValid() { + return mWord != null; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] { mWord, mIsBeginningOfSentence } ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof WordInfo)) return false; + final WordInfo wordInfo = (WordInfo)o; + if (mWord == null || wordInfo.mWord == null) { + return mWord == wordInfo.mWord + && mIsBeginningOfSentence == wordInfo.mIsBeginningOfSentence; + } + return TextUtils.equals(mWord, wordInfo.mWord) + && mIsBeginningOfSentence == wordInfo.mIsBeginningOfSentence; + } + } + + // The words immediately before the considered word. EMPTY_WORD_INFO element means we don't + // have any context for that previous word including the "beginning of sentence context" - we + // just don't know what to predict using the information. An example of that is after a comma. + // For simplicity of implementation, elements may also be EMPTY_WORD_INFO transiently after the + // WordComposer was reset and before starting a new composing word, but we should never be + // calling getSuggetions* in this situation. + private final WordInfo[] mPrevWordsInfo; + private final int mPrevWordsCount; + + private final int mMaxPrevWordCount; + + // Construct from the previous word information. + public NgramContext(final WordInfo... prevWordsInfo) { + this(DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM, prevWordsInfo); + } + + public NgramContext(final int maxPrevWordCount, final WordInfo... prevWordsInfo) { + mPrevWordsInfo = prevWordsInfo; + mPrevWordsCount = prevWordsInfo.length; + mMaxPrevWordCount = maxPrevWordCount; + } + + /** + * Create next prevWordsInfo using current prevWordsInfo. + */ + @Nonnull + public NgramContext getNextNgramContext(final WordInfo wordInfo) { + final int nextPrevWordCount = Math.min(mMaxPrevWordCount, mPrevWordsCount + 1); + final WordInfo[] prevWordsInfo = new WordInfo[nextPrevWordCount]; + prevWordsInfo[0] = wordInfo; + System.arraycopy(mPrevWordsInfo, 0, prevWordsInfo, 1, nextPrevWordCount - 1); + return new NgramContext(mMaxPrevWordCount, prevWordsInfo); + } + + + /** + * Extracts the previous words context. + * + * @return a String with the previous words separated by white space. + */ + public String extractPrevWordsContext() { + final ArrayList<String> terms = new ArrayList<>(); + for (int i = mPrevWordsInfo.length - 1; i >= 0; --i) { + if (mPrevWordsInfo[i] != null && mPrevWordsInfo[i].isValid()) { + final NgramContext.WordInfo wordInfo = mPrevWordsInfo[i]; + if (wordInfo.mIsBeginningOfSentence) { + terms.add(BEGINNING_OF_SENTENCE_TAG); + } else { + final String term = wordInfo.mWord.toString(); + if (!term.isEmpty()) { + terms.add(term); + } + } + } + } + return TextUtils.join(CONTEXT_SEPARATOR, terms); + } + + /** + * Extracts the previous words context. + * + * @return a String array with the previous words. + */ + public String[] extractPrevWordsContextArray() { + final ArrayList<String> prevTermList = new ArrayList<>(); + for (int i = mPrevWordsInfo.length - 1; i >= 0; --i) { + if (mPrevWordsInfo[i] != null && mPrevWordsInfo[i].isValid()) { + final NgramContext.WordInfo wordInfo = mPrevWordsInfo[i]; + if (wordInfo.mIsBeginningOfSentence) { + prevTermList.add(BEGINNING_OF_SENTENCE_TAG); + } else { + final String term = wordInfo.mWord.toString(); + if (!term.isEmpty()) { + prevTermList.add(term); + } + } + } + } + final String[] contextStringArray = prevTermList.toArray(new String[prevTermList.size()]); + return contextStringArray; + } + + public boolean isValid() { + return mPrevWordsCount > 0 && mPrevWordsInfo[0].isValid(); + } + + public boolean isBeginningOfSentenceContext() { + return mPrevWordsCount > 0 && mPrevWordsInfo[0].mIsBeginningOfSentence; + } + + // n is 1-indexed. + // TODO: Remove + public CharSequence getNthPrevWord(final int n) { + if (n <= 0 || n > mPrevWordsCount) { + return null; + } + return mPrevWordsInfo[n - 1].mWord; + } + + // n is 1-indexed. + @UsedForTesting + public boolean isNthPrevWordBeginningOfSentence(final int n) { + if (n <= 0 || n > mPrevWordsCount) { + return false; + } + return mPrevWordsInfo[n - 1].mIsBeginningOfSentence; + } + + public void outputToArray(final int[][] codePointArrays, + final boolean[] isBeginningOfSentenceArray) { + for (int i = 0; i < mPrevWordsCount; i++) { + final WordInfo wordInfo = mPrevWordsInfo[i]; + if (wordInfo == null || !wordInfo.isValid()) { + codePointArrays[i] = new int[0]; + isBeginningOfSentenceArray[i] = false; + continue; + } + codePointArrays[i] = StringUtils.toCodePointArray(wordInfo.mWord); + isBeginningOfSentenceArray[i] = wordInfo.mIsBeginningOfSentence; + } + } + + public int getPrevWordCount() { + return mPrevWordsCount; + } + + @Override + public int hashCode() { + int hashValue = 0; + for (final WordInfo wordInfo : mPrevWordsInfo) { + if (wordInfo == null || !WordInfo.EMPTY_WORD_INFO.equals(wordInfo)) { + break; + } + hashValue ^= wordInfo.hashCode(); + } + return hashValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof NgramContext)) return false; + final NgramContext prevWordsInfo = (NgramContext)o; + + final int minLength = Math.min(mPrevWordsCount, prevWordsInfo.mPrevWordsCount); + for (int i = 0; i < minLength; i++) { + if (!mPrevWordsInfo[i].equals(prevWordsInfo.mPrevWordsInfo[i])) { + return false; + } + } + final WordInfo[] longerWordsInfo; + final int longerWordsInfoCount; + if (mPrevWordsCount > prevWordsInfo.mPrevWordsCount) { + longerWordsInfo = mPrevWordsInfo; + longerWordsInfoCount = mPrevWordsCount; + } else { + longerWordsInfo = prevWordsInfo.mPrevWordsInfo; + longerWordsInfoCount = prevWordsInfo.mPrevWordsCount; + } + for (int i = minLength; i < longerWordsInfoCount; i++) { + if (longerWordsInfo[i] != null + && !WordInfo.EMPTY_WORD_INFO.equals(longerWordsInfo[i])) { + return false; + } + } + return true; + } + + @Override + public String toString() { + final StringBuffer builder = new StringBuffer(); + for (int i = 0; i < mPrevWordsCount; i++) { + final WordInfo wordInfo = mPrevWordsInfo[i]; + builder.append("PrevWord["); + builder.append(i); + builder.append("]: "); + if (wordInfo == null) { + builder.append("null. "); + continue; + } + if (!wordInfo.isValid()) { + builder.append("Empty. "); + continue; + } + builder.append(wordInfo.mWord); + builder.append(", isBeginningOfSentence: "); + builder.append(wordInfo.mIsBeginningOfSentence); + builder.append(". "); + } + return builder.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/PunctuationSuggestions.java b/java/src/org/kelar/inputmethod/latin/PunctuationSuggestions.java new file mode 100644 index 000000000..70a2da107 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/PunctuationSuggestions.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.keyboard.internal.KeySpecParser; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; + +import javax.annotation.Nullable; + +/** + * The extended {@link SuggestedWords} class to represent punctuation suggestions. + * + * Each punctuation specification string is the key specification that can be parsed by + * {@link KeySpecParser}. + */ +public final class PunctuationSuggestions extends SuggestedWords { + private PunctuationSuggestions(final ArrayList<SuggestedWordInfo> punctuationsList) { + super(punctuationsList, + null /* rawSuggestions */, + null /* typedWord */, + false /* typedWordValid */, + false /* hasAutoCorrectionCandidate */, + false /* isObsoleteSuggestions */, + INPUT_STYLE_NONE /* inputStyle */, + SuggestedWords.NOT_A_SEQUENCE_NUMBER); + } + + /** + * Create new instance of {@link PunctuationSuggestions} from the array of punctuation key + * specifications. + * + * @param punctuationSpecs The array of punctuation key specifications. + * @return The {@link PunctuationSuggestions} object. + */ + public static PunctuationSuggestions newPunctuationSuggestions( + @Nullable final String[] punctuationSpecs) { + if (punctuationSpecs == null || punctuationSpecs.length == 0) { + return new PunctuationSuggestions(new ArrayList<SuggestedWordInfo>(0)); + } + final ArrayList<SuggestedWordInfo> punctuationList = + new ArrayList<>(punctuationSpecs.length); + for (String spec : punctuationSpecs) { + punctuationList.add(newHardCodedWordInfo(spec)); + } + return new PunctuationSuggestions(punctuationList); + } + + /** + * {@inheritDoc} + * Note that {@link SuggestedWords#getWord(int)} returns a punctuation key specification text. + * The suggested punctuation should be gotten by parsing the key specification. + */ + @Override + public String getWord(final int index) { + final String keySpec = super.getWord(index); + final int code = KeySpecParser.getCode(keySpec); + return (code == Constants.CODE_OUTPUT_TEXT) + ? KeySpecParser.getOutputText(keySpec) + : StringUtils.newSingleCodePointString(code); + } + + /** + * {@inheritDoc} + * Note that {@link SuggestedWords#getWord(int)} returns a punctuation key specification text. + * The displayed text should be gotten by parsing the key specification. + */ + @Override + public String getLabel(final int index) { + final String keySpec = super.getWord(index); + return KeySpecParser.getLabel(keySpec); + } + + /** + * {@inheritDoc} + * Note that {@link #getWord(int)} returns a suggested punctuation. We should create a + * {@link SuggestedWords.SuggestedWordInfo} object that represents a hard coded word. + */ + @Override + public SuggestedWordInfo getInfo(final int index) { + return newHardCodedWordInfo(getWord(index)); + } + + /** + * The predicator to tell whether this object represents punctuation suggestions. + * @return true if this object represents punctuation suggestions. + */ + @Override + public boolean isPunctuationSuggestions() { + return true; + } + + @Override + public String toString() { + return "PunctuationSuggestions: " + + " words=" + Arrays.toString(mSuggestedWordInfoList.toArray()); + } + + private static SuggestedWordInfo newHardCodedWordInfo(final String keySpec) { + return new SuggestedWordInfo(keySpec, "" /* prevWordsContext */, + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_HARDCODED, + Dictionary.DICTIONARY_HARDCODED, + SuggestedWordInfo.NOT_AN_INDEX /* indexOfTouchPointOfSecondWord */, + SuggestedWordInfo.NOT_A_CONFIDENCE /* autoCommitFirstWordConfidence */); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/ReadOnlyBinaryDictionary.java b/java/src/org/kelar/inputmethod/latin/ReadOnlyBinaryDictionary.java new file mode 100644 index 000000000..7e4eaed45 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/ReadOnlyBinaryDictionary.java @@ -0,0 +1,127 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * This class provides binary dictionary reading operations with locking. An instance of this class + * can be used by multiple threads. Note that different session IDs must be used when multiple + * threads get suggestions using this class. + */ +public final class ReadOnlyBinaryDictionary extends Dictionary { + /** + * A lock for accessing binary dictionary. Only closing binary dictionary is the operation + * that change the state of dictionary. + */ + private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock(); + + private final BinaryDictionary mBinaryDictionary; + + public ReadOnlyBinaryDictionary(final String filename, final long offset, final long length, + final boolean useFullEditDistance, final Locale locale, final String dictType) { + super(dictType, locale); + mBinaryDictionary = new BinaryDictionary(filename, offset, length, useFullEditDistance, + locale, dictType, false /* isUpdatable */); + } + + public boolean isValidDictionary() { + return mBinaryDictionary.isValidDictionary(); + } + + @Override + public ArrayList<SuggestedWordInfo> getSuggestions(final ComposedData composedData, + final NgramContext ngramContext, final long proximityInfoHandle, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int sessionId, final float weightForLocale, + final float[] inOutWeightOfLangModelVsSpatialModel) { + if (mLock.readLock().tryLock()) { + try { + return mBinaryDictionary.getSuggestions(composedData, ngramContext, + proximityInfoHandle, settingsValuesForSuggestion, sessionId, + weightForLocale, inOutWeightOfLangModelVsSpatialModel); + } finally { + mLock.readLock().unlock(); + } + } + return null; + } + + @Override + public boolean isInDictionary(final String word) { + if (mLock.readLock().tryLock()) { + try { + return mBinaryDictionary.isInDictionary(word); + } finally { + mLock.readLock().unlock(); + } + } + return false; + } + + @Override + public boolean shouldAutoCommit(final SuggestedWordInfo candidate) { + if (mLock.readLock().tryLock()) { + try { + return mBinaryDictionary.shouldAutoCommit(candidate); + } finally { + mLock.readLock().unlock(); + } + } + return false; + } + + @Override + public int getFrequency(final String word) { + if (mLock.readLock().tryLock()) { + try { + return mBinaryDictionary.getFrequency(word); + } finally { + mLock.readLock().unlock(); + } + } + return NOT_A_PROBABILITY; + } + + @Override + public int getMaxFrequencyOfExactMatches(final String word) { + if (mLock.readLock().tryLock()) { + try { + return mBinaryDictionary.getMaxFrequencyOfExactMatches(word); + } finally { + mLock.readLock().unlock(); + } + } + return NOT_A_PROBABILITY; + } + + @Override + public void close() { + mLock.writeLock().lock(); + try { + mBinaryDictionary.close(); + } finally { + mLock.writeLock().unlock(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/RichInputConnection.java b/java/src/org/kelar/inputmethod/latin/RichInputConnection.java new file mode 100644 index 000000000..381560945 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/RichInputConnection.java @@ -0,0 +1,1033 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.inputmethodservice.InputMethodService; +import android.os.Build; +import android.os.Bundle; +import android.os.SystemClock; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.inputmethod.CompletionInfo; +import android.view.inputmethod.CorrectionInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; + +import org.kelar.inputmethod.compat.InputConnectionCompatUtils; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.UnicodeSurrogate; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.inputlogic.PrivateCommandPerformer; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; +import org.kelar.inputmethod.latin.utils.CapsModeUtils; +import org.kelar.inputmethod.latin.utils.DebugLogUtils; +import org.kelar.inputmethod.latin.utils.NgramContextUtils; +import org.kelar.inputmethod.latin.utils.ScriptUtils; +import org.kelar.inputmethod.latin.utils.SpannableStringUtils; +import org.kelar.inputmethod.latin.utils.StatsUtils; +import org.kelar.inputmethod.latin.utils.TextRange; + +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Enrichment class for InputConnection to simplify interaction and add functionality. + * + * This class serves as a wrapper to be able to simply add hooks to any calls to the underlying + * InputConnection. It also keeps track of a number of things to avoid having to call upon IPC + * all the time to find out what text is in the buffer, when we need it to determine caps mode + * for example. + */ +public final class RichInputConnection implements PrivateCommandPerformer { + private static final String TAG = "RichInputConnection"; + private static final boolean DBG = false; + private static final boolean DEBUG_PREVIOUS_TEXT = false; + private static final boolean DEBUG_BATCH_NESTING = false; + private static final int NUM_CHARS_TO_GET_BEFORE_CURSOR = 40; + private static final int NUM_CHARS_TO_GET_AFTER_CURSOR = 40; + private static final int INVALID_CURSOR_POSITION = -1; + + /** + * The amount of time a {@link #reloadTextCache} call needs to take for the keyboard to enter + * the {@link #hasSlowInputConnection} state. + */ + private static final long SLOW_INPUT_CONNECTION_ON_FULL_RELOAD_MS = 1000; + /** + * The amount of time a {@link #getTextBeforeCursor} or {@link #getTextAfterCursor} call needs + * to take for the keyboard to enter the {@link #hasSlowInputConnection} state. + */ + private static final long SLOW_INPUT_CONNECTION_ON_PARTIAL_RELOAD_MS = 200; + + private static final int OPERATION_GET_TEXT_BEFORE_CURSOR = 0; + private static final int OPERATION_GET_TEXT_AFTER_CURSOR = 1; + private static final int OPERATION_GET_WORD_RANGE_AT_CURSOR = 2; + private static final int OPERATION_RELOAD_TEXT_CACHE = 3; + private static final String[] OPERATION_NAMES = new String[] { + "GET_TEXT_BEFORE_CURSOR", + "GET_TEXT_AFTER_CURSOR", + "GET_WORD_RANGE_AT_CURSOR", + "RELOAD_TEXT_CACHE"}; + + /** + * The amount of time the keyboard will persist in the {@link #hasSlowInputConnection} state + * after observing a slow InputConnection event. + */ + private static final long SLOW_INPUTCONNECTION_PERSIST_MS = TimeUnit.MINUTES.toMillis(10); + + /** + * This variable contains an expected value for the selection start position. This is where the + * cursor or selection start may end up after all the keyboard-triggered updates have passed. We + * keep this to compare it to the actual selection start to guess whether the move was caused by + * a keyboard command or not. + * It's not really the selection start position: the selection start may not be there yet, and + * in some cases, it may never arrive there. + */ + private int mExpectedSelStart = INVALID_CURSOR_POSITION; // in chars, not code points + /** + * The expected selection end. Only differs from mExpectedSelStart if a non-empty selection is + * expected. The same caveats as mExpectedSelStart apply. + */ + private int mExpectedSelEnd = INVALID_CURSOR_POSITION; // in chars, not code points + /** + * This contains the committed text immediately preceding the cursor and the composing + * text, if any. It is refreshed when the cursor moves by calling upon the TextView. + */ + private final StringBuilder mCommittedTextBeforeComposingText = new StringBuilder(); + /** + * This contains the currently composing text, as LatinIME thinks the TextView is seeing it. + */ + private final StringBuilder mComposingText = new StringBuilder(); + + /** + * This variable is a temporary object used in {@link #commitText(CharSequence,int)} + * to avoid object creation. + */ + private SpannableStringBuilder mTempObjectForCommitText = new SpannableStringBuilder(); + + private final InputMethodService mParent; + private InputConnection mIC; + private int mNestLevel; + + /** + * The timestamp of the last slow InputConnection operation + */ + private long mLastSlowInputConnectionTime = -SLOW_INPUTCONNECTION_PERSIST_MS; + + public RichInputConnection(final InputMethodService parent) { + mParent = parent; + mIC = null; + mNestLevel = 0; + } + + public boolean isConnected() { + return mIC != null; + } + + /** + * Returns whether or not the underlying InputConnection is slow. When true, we want to avoid + * calling InputConnection methods that trigger an IPC round-trip (e.g., getTextAfterCursor). + */ + public boolean hasSlowInputConnection() { + return (SystemClock.uptimeMillis() - mLastSlowInputConnectionTime) + <= SLOW_INPUTCONNECTION_PERSIST_MS; + } + + public void onStartInput() { + mLastSlowInputConnectionTime = -SLOW_INPUTCONNECTION_PERSIST_MS; + } + + private void checkConsistencyForDebug() { + final ExtractedTextRequest r = new ExtractedTextRequest(); + r.hintMaxChars = 0; + r.hintMaxLines = 0; + r.token = 1; + r.flags = 0; + final ExtractedText et = mIC.getExtractedText(r, 0); + final CharSequence beforeCursor = getTextBeforeCursor(Constants.EDITOR_CONTENTS_CACHE_SIZE, + 0); + final StringBuilder internal = new StringBuilder(mCommittedTextBeforeComposingText) + .append(mComposingText); + if (null == et || null == beforeCursor) return; + final int actualLength = Math.min(beforeCursor.length(), internal.length()); + if (internal.length() > actualLength) { + internal.delete(0, internal.length() - actualLength); + } + final String reference = (beforeCursor.length() <= actualLength) ? beforeCursor.toString() + : beforeCursor.subSequence(beforeCursor.length() - actualLength, + beforeCursor.length()).toString(); + if (et.selectionStart != mExpectedSelStart + || !(reference.equals(internal.toString()))) { + final String context = "Expected selection start = " + mExpectedSelStart + + "\nActual selection start = " + et.selectionStart + + "\nExpected text = " + internal.length() + " " + internal + + "\nActual text = " + reference.length() + " " + reference; + ((LatinIME)mParent).debugDumpStateAndCrashWithException(context); + } else { + Log.e(TAG, DebugLogUtils.getStackTrace(2)); + Log.e(TAG, "Exp <> Actual : " + mExpectedSelStart + " <> " + et.selectionStart); + } + } + + public void beginBatchEdit() { + if (++mNestLevel == 1) { + mIC = mParent.getCurrentInputConnection(); + if (isConnected()) { + mIC.beginBatchEdit(); + } + } else { + if (DBG) { + throw new RuntimeException("Nest level too deep"); + } + Log.e(TAG, "Nest level too deep : " + mNestLevel); + } + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + public void endBatchEdit() { + if (mNestLevel <= 0) Log.e(TAG, "Batch edit not in progress!"); // TODO: exception instead + if (--mNestLevel == 0 && isConnected()) { + mIC.endBatchEdit(); + } + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + /** + * Reset the cached text and retrieve it again from the editor. + * + * This should be called when the cursor moved. It's possible that we can't connect to + * the application when doing this; notably, this happens sometimes during rotation, probably + * because of a race condition in the framework. In this case, we just can't retrieve the + * data, so we empty the cache and note that we don't know the new cursor position, and we + * return false so that the caller knows about this and can retry later. + * + * @param newSelStart the new position of the selection start, as received from the system. + * @param newSelEnd the new position of the selection end, as received from the system. + * @param shouldFinishComposition whether we should finish the composition in progress. + * @return true if we were able to connect to the editor successfully, false otherwise. When + * this method returns false, the caches could not be correctly refreshed so they were only + * reset: the caller should try again later to return to normal operation. + */ + public boolean resetCachesUponCursorMoveAndReturnSuccess(final int newSelStart, + final int newSelEnd, final boolean shouldFinishComposition) { + mExpectedSelStart = newSelStart; + mExpectedSelEnd = newSelEnd; + mComposingText.setLength(0); + final boolean didReloadTextSuccessfully = reloadTextCache(); + if (!didReloadTextSuccessfully) { + Log.d(TAG, "Will try to retrieve text later."); + return false; + } + if (isConnected() && shouldFinishComposition) { + mIC.finishComposingText(); + } + return true; + } + + /** + * Reload the cached text from the InputConnection. + * + * @return true if successful + */ + private boolean reloadTextCache() { + mCommittedTextBeforeComposingText.setLength(0); + mIC = mParent.getCurrentInputConnection(); + // Call upon the inputconnection directly since our own method is using the cache, and + // we want to refresh it. + final CharSequence textBeforeCursor = getTextBeforeCursorAndDetectLaggyConnection( + OPERATION_RELOAD_TEXT_CACHE, + SLOW_INPUT_CONNECTION_ON_FULL_RELOAD_MS, + Constants.EDITOR_CONTENTS_CACHE_SIZE, + 0 /* flags */); + if (null == textBeforeCursor) { + // For some reason the app thinks we are not connected to it. This looks like a + // framework bug... Fall back to ground state and return false. + mExpectedSelStart = INVALID_CURSOR_POSITION; + mExpectedSelEnd = INVALID_CURSOR_POSITION; + Log.e(TAG, "Unable to connect to the editor to retrieve text."); + return false; + } + mCommittedTextBeforeComposingText.append(textBeforeCursor); + return true; + } + + private void checkBatchEdit() { + if (mNestLevel != 1) { + // TODO: exception instead + Log.e(TAG, "Batch edit level incorrect : " + mNestLevel); + Log.e(TAG, DebugLogUtils.getStackTrace(4)); + } + } + + public void finishComposingText() { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + // TODO: this is not correct! The cursor is not necessarily after the composing text. + // In the practice right now this is only called when input ends so it will be reset so + // it works, but it's wrong and should be fixed. + mCommittedTextBeforeComposingText.append(mComposingText); + mComposingText.setLength(0); + if (isConnected()) { + mIC.finishComposingText(); + } + } + + /** + * Calls {@link InputConnection#commitText(CharSequence, int)}. + * + * @param text The text to commit. This may include styles. + * @param newCursorPosition The new cursor position around the text. + */ + public void commitText(final CharSequence text, final int newCursorPosition) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + mCommittedTextBeforeComposingText.append(text); + // TODO: the following is exceedingly error-prone. Right now when the cursor is in the + // middle of the composing word mComposingText only holds the part of the composing text + // that is before the cursor, so this actually works, but it's terribly confusing. Fix this. + mExpectedSelStart += text.length() - mComposingText.length(); + mExpectedSelEnd = mExpectedSelStart; + mComposingText.setLength(0); + if (isConnected()) { + mTempObjectForCommitText.clear(); + mTempObjectForCommitText.append(text); + final CharacterStyle[] spans = mTempObjectForCommitText.getSpans( + 0, text.length(), CharacterStyle.class); + for (final CharacterStyle span : spans) { + final int spanStart = mTempObjectForCommitText.getSpanStart(span); + final int spanEnd = mTempObjectForCommitText.getSpanEnd(span); + final int spanFlags = mTempObjectForCommitText.getSpanFlags(span); + // We have to adjust the end of the span to include an additional character. + // This is to avoid splitting a unicode surrogate pair. + // See org.kelar.inputmethod.latin.common.Constants.UnicodeSurrogate + // See https://b.corp.google.com/issues/19255233 + if (0 < spanEnd && spanEnd < mTempObjectForCommitText.length()) { + final char spanEndChar = mTempObjectForCommitText.charAt(spanEnd - 1); + final char nextChar = mTempObjectForCommitText.charAt(spanEnd); + if (UnicodeSurrogate.isLowSurrogate(spanEndChar) + && UnicodeSurrogate.isHighSurrogate(nextChar)) { + mTempObjectForCommitText.setSpan(span, spanStart, spanEnd + 1, spanFlags); + } + } + } + mIC.commitText(mTempObjectForCommitText, newCursorPosition); + } + } + + @Nullable + public CharSequence getSelectedText(final int flags) { + return isConnected() ? mIC.getSelectedText(flags) : null; + } + + public boolean canDeleteCharacters() { + return mExpectedSelStart > 0; + } + + /** + * Gets the caps modes we should be in after this specific string. + * + * This returns a bit set of TextUtils#CAP_MODE_*, masked by the inputType argument. + * This method also supports faking an additional space after the string passed in argument, + * to support cases where a space will be added automatically, like in phantom space + * state for example. + * Note that for English, we are using American typography rules (which are not specific to + * American English, it's just the most common set of rules for English). + * + * @param inputType a mask of the caps modes to test for. + * @param spacingAndPunctuations the values of the settings to use for locale and separators. + * @param hasSpaceBefore if we should consider there should be a space after the string. + * @return the caps modes that should be on as a set of bits + */ + public int getCursorCapsMode(final int inputType, + final SpacingAndPunctuations spacingAndPunctuations, final boolean hasSpaceBefore) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return Constants.TextUtils.CAP_MODE_OFF; + } + if (!TextUtils.isEmpty(mComposingText)) { + if (hasSpaceBefore) { + // If we have some composing text and a space before, then we should have + // MODE_CHARACTERS and MODE_WORDS on. + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS) & inputType; + } + // We have some composing text - we should be in MODE_CHARACTERS only. + return TextUtils.CAP_MODE_CHARACTERS & inputType; + } + // TODO: this will generally work, but there may be cases where the buffer contains SOME + // information but not enough to determine the caps mode accurately. This may happen after + // heavy pressing of delete, for example DEFAULT_TEXT_CACHE_SIZE - 5 times or so. + // getCapsMode should be updated to be able to return a "not enough info" result so that + // we can get more context only when needed. + if (TextUtils.isEmpty(mCommittedTextBeforeComposingText) && 0 != mExpectedSelStart) { + if (!reloadTextCache()) { + Log.w(TAG, "Unable to connect to the editor. " + + "Setting caps mode without knowing text."); + } + } + // This never calls InputConnection#getCapsMode - in fact, it's a static method that + // never blocks or initiates IPC. + // TODO: don't call #toString() here. Instead, all accesses to + // mCommittedTextBeforeComposingText should be done on the main thread. + return CapsModeUtils.getCapsMode(mCommittedTextBeforeComposingText.toString(), inputType, + spacingAndPunctuations, hasSpaceBefore); + } + + public int getCodePointBeforeCursor() { + final int length = mCommittedTextBeforeComposingText.length(); + if (length < 1) return Constants.NOT_A_CODE; + return Character.codePointBefore(mCommittedTextBeforeComposingText, length); + } + + public CharSequence getTextBeforeCursor(final int n, final int flags) { + final int cachedLength = + mCommittedTextBeforeComposingText.length() + mComposingText.length(); + // If we have enough characters to satisfy the request, or if we have all characters in + // the text field, then we can return the cached version right away. + // However, if we don't have an expected cursor position, then we should always + // go fetch the cache again (as it happens, INVALID_CURSOR_POSITION < 0, so we need to + // test for this explicitly) + if (INVALID_CURSOR_POSITION != mExpectedSelStart + && (cachedLength >= n || cachedLength >= mExpectedSelStart)) { + final StringBuilder s = new StringBuilder(mCommittedTextBeforeComposingText); + // We call #toString() here to create a temporary object. + // In some situations, this method is called on a worker thread, and it's possible + // the main thread touches the contents of mComposingText while this worker thread + // is suspended, because mComposingText is a StringBuilder. This may lead to crashes, + // so we call #toString() on it. That will result in the return value being strictly + // speaking wrong, but since this is used for basing bigram probability off, and + // it's only going to matter for one getSuggestions call, it's fine in the practice. + s.append(mComposingText.toString()); + if (s.length() > n) { + s.delete(0, s.length() - n); + } + return s; + } + return getTextBeforeCursorAndDetectLaggyConnection( + OPERATION_GET_TEXT_BEFORE_CURSOR, + SLOW_INPUT_CONNECTION_ON_PARTIAL_RELOAD_MS, + n, flags); + } + + private CharSequence getTextBeforeCursorAndDetectLaggyConnection( + final int operation, final long timeout, final int n, final int flags) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return null; + } + final long startTime = SystemClock.uptimeMillis(); + final CharSequence result = mIC.getTextBeforeCursor(n, flags); + detectLaggyConnection(operation, timeout, startTime); + return result; + } + + public CharSequence getTextAfterCursor(final int n, final int flags) { + return getTextAfterCursorAndDetectLaggyConnection( + OPERATION_GET_TEXT_AFTER_CURSOR, + SLOW_INPUT_CONNECTION_ON_PARTIAL_RELOAD_MS, + n, flags); + } + + private CharSequence getTextAfterCursorAndDetectLaggyConnection( + final int operation, final long timeout, final int n, final int flags) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return null; + } + final long startTime = SystemClock.uptimeMillis(); + final CharSequence result = mIC.getTextAfterCursor(n, flags); + detectLaggyConnection(operation, timeout, startTime); + return result; + } + + private void detectLaggyConnection(final int operation, final long timeout, final long startTime) { + final long duration = SystemClock.uptimeMillis() - startTime; + if (duration >= timeout) { + final String operationName = OPERATION_NAMES[operation]; + Log.w(TAG, "Slow InputConnection: " + operationName + " took " + duration + " ms."); + StatsUtils.onInputConnectionLaggy(operation, duration); + mLastSlowInputConnectionTime = SystemClock.uptimeMillis(); + } + } + + public void deleteTextBeforeCursor(final int beforeLength) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + // TODO: the following is incorrect if the cursor is not immediately after the composition. + // Right now we never come here in this case because we reset the composing state before we + // come here in this case, but we need to fix this. + final int remainingChars = mComposingText.length() - beforeLength; + if (remainingChars >= 0) { + mComposingText.setLength(remainingChars); + } else { + mComposingText.setLength(0); + // Never cut under 0 + final int len = Math.max(mCommittedTextBeforeComposingText.length() + + remainingChars, 0); + mCommittedTextBeforeComposingText.setLength(len); + } + if (mExpectedSelStart > beforeLength) { + mExpectedSelStart -= beforeLength; + mExpectedSelEnd -= beforeLength; + } else { + // There are fewer characters before the cursor in the buffer than we are being asked to + // delete. Only delete what is there, and update the end with the amount deleted. + mExpectedSelEnd -= mExpectedSelStart; + mExpectedSelStart = 0; + } + if (isConnected()) { + mIC.deleteSurroundingText(beforeLength, 0); + } + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + public void performEditorAction(final int actionId) { + mIC = mParent.getCurrentInputConnection(); + if (isConnected()) { + mIC.performEditorAction(actionId); + } + } + + public void sendKeyEvent(final KeyEvent keyEvent) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + // This method is only called for enter or backspace when speaking to old applications + // (target SDK <= 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1)), or for digits. + // When talking to new applications we never use this method because it's inherently + // racy and has unpredictable results, but for backward compatibility we continue + // sending the key events for only Enter and Backspace because some applications + // mistakenly catch them to do some stuff. + switch (keyEvent.getKeyCode()) { + case KeyEvent.KEYCODE_ENTER: + mCommittedTextBeforeComposingText.append("\n"); + mExpectedSelStart += 1; + mExpectedSelEnd = mExpectedSelStart; + break; + case KeyEvent.KEYCODE_DEL: + if (0 == mComposingText.length()) { + if (mCommittedTextBeforeComposingText.length() > 0) { + mCommittedTextBeforeComposingText.delete( + mCommittedTextBeforeComposingText.length() - 1, + mCommittedTextBeforeComposingText.length()); + } + } else { + mComposingText.delete(mComposingText.length() - 1, mComposingText.length()); + } + if (mExpectedSelStart > 0 && mExpectedSelStart == mExpectedSelEnd) { + // TODO: Handle surrogate pairs. + mExpectedSelStart -= 1; + } + mExpectedSelEnd = mExpectedSelStart; + break; + case KeyEvent.KEYCODE_UNKNOWN: + if (null != keyEvent.getCharacters()) { + mCommittedTextBeforeComposingText.append(keyEvent.getCharacters()); + mExpectedSelStart += keyEvent.getCharacters().length(); + mExpectedSelEnd = mExpectedSelStart; + } + break; + default: + final String text = StringUtils.newSingleCodePointString(keyEvent.getUnicodeChar()); + mCommittedTextBeforeComposingText.append(text); + mExpectedSelStart += text.length(); + mExpectedSelEnd = mExpectedSelStart; + break; + } + } + if (isConnected()) { + mIC.sendKeyEvent(keyEvent); + } + } + + public void setComposingRegion(final int start, final int end) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + final CharSequence textBeforeCursor = + getTextBeforeCursor(Constants.EDITOR_CONTENTS_CACHE_SIZE + (end - start), 0); + mCommittedTextBeforeComposingText.setLength(0); + if (!TextUtils.isEmpty(textBeforeCursor)) { + // The cursor is not necessarily at the end of the composing text, but we have its + // position in mExpectedSelStart and mExpectedSelEnd. In this case we want the start + // of the text, so we should use mExpectedSelStart. In other words, the composing + // text starts (mExpectedSelStart - start) characters before the end of textBeforeCursor + final int indexOfStartOfComposingText = + Math.max(textBeforeCursor.length() - (mExpectedSelStart - start), 0); + mComposingText.append(textBeforeCursor.subSequence(indexOfStartOfComposingText, + textBeforeCursor.length())); + mCommittedTextBeforeComposingText.append( + textBeforeCursor.subSequence(0, indexOfStartOfComposingText)); + } + if (isConnected()) { + mIC.setComposingRegion(start, end); + } + } + + public void setComposingText(final CharSequence text, final int newCursorPosition) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + mExpectedSelStart += text.length() - mComposingText.length(); + mExpectedSelEnd = mExpectedSelStart; + mComposingText.setLength(0); + mComposingText.append(text); + // TODO: support values of newCursorPosition != 1. At this time, this is never called with + // newCursorPosition != 1. + if (isConnected()) { + mIC.setComposingText(text, newCursorPosition); + } + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + /** + * Set the selection of the text editor. + * + * Calls through to {@link InputConnection#setSelection(int, int)}. + * + * @param start the character index where the selection should start. + * @param end the character index where the selection should end. + * @return Returns true on success, false on failure: either the input connection is no longer + * valid when setting the selection or when retrieving the text cache at that point, or + * invalid arguments were passed. + */ + public boolean setSelection(final int start, final int end) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + if (start < 0 || end < 0) { + return false; + } + mExpectedSelStart = start; + mExpectedSelEnd = end; + if (isConnected()) { + final boolean isIcValid = mIC.setSelection(start, end); + if (!isIcValid) { + return false; + } + } + return reloadTextCache(); + } + + public void commitCorrection(final CorrectionInfo correctionInfo) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + // This has no effect on the text field and does not change its content. It only makes + // TextView flash the text for a second based on indices contained in the argument. + if (isConnected()) { + mIC.commitCorrection(correctionInfo); + } + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + public void commitCompletion(final CompletionInfo completionInfo) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + CharSequence text = completionInfo.getText(); + // text should never be null, but just in case, it's better to insert nothing than to crash + if (null == text) text = ""; + mCommittedTextBeforeComposingText.append(text); + mExpectedSelStart += text.length() - mComposingText.length(); + mExpectedSelEnd = mExpectedSelStart; + mComposingText.setLength(0); + if (isConnected()) { + mIC.commitCompletion(completionInfo); + } + if (DEBUG_PREVIOUS_TEXT) checkConsistencyForDebug(); + } + + @SuppressWarnings("unused") + @Nonnull + public NgramContext getNgramContextFromNthPreviousWord( + final SpacingAndPunctuations spacingAndPunctuations, final int n) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return NgramContext.EMPTY_PREV_WORDS_INFO; + } + final CharSequence prev = getTextBeforeCursor(NUM_CHARS_TO_GET_BEFORE_CURSOR, 0); + if (DEBUG_PREVIOUS_TEXT && null != prev) { + final int checkLength = NUM_CHARS_TO_GET_BEFORE_CURSOR - 1; + final String reference = prev.length() <= checkLength ? prev.toString() + : prev.subSequence(prev.length() - checkLength, prev.length()).toString(); + // TODO: right now the following works because mComposingText holds the part of the + // composing text that is before the cursor, but this is very confusing. We should + // fix it. + final StringBuilder internal = new StringBuilder() + .append(mCommittedTextBeforeComposingText).append(mComposingText); + if (internal.length() > checkLength) { + internal.delete(0, internal.length() - checkLength); + if (!(reference.equals(internal.toString()))) { + final String context = + "Expected text = " + internal + "\nActual text = " + reference; + ((LatinIME)mParent).debugDumpStateAndCrashWithException(context); + } + } + } + return NgramContextUtils.getNgramContextFromNthPreviousWord( + prev, spacingAndPunctuations, n); + } + + private static boolean isPartOfCompositionForScript(final int codePoint, + final SpacingAndPunctuations spacingAndPunctuations, final int scriptId) { + // We always consider word connectors part of compositions. + return spacingAndPunctuations.isWordConnector(codePoint) + // Otherwise, it's part of composition if it's part of script and not a separator. + || (!spacingAndPunctuations.isWordSeparator(codePoint) + && ScriptUtils.isLetterPartOfScript(codePoint, scriptId)); + } + + /** + * Returns the text surrounding the cursor. + * + * @param spacingAndPunctuations the rules for spacing and punctuation + * @param scriptId the script we consider to be writing words, as one of ScriptUtils.SCRIPT_* + * @return a range containing the text surrounding the cursor + */ + public TextRange getWordRangeAtCursor(final SpacingAndPunctuations spacingAndPunctuations, + final int scriptId) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return null; + } + final CharSequence before = getTextBeforeCursorAndDetectLaggyConnection( + OPERATION_GET_WORD_RANGE_AT_CURSOR, + SLOW_INPUT_CONNECTION_ON_PARTIAL_RELOAD_MS, + NUM_CHARS_TO_GET_BEFORE_CURSOR, + InputConnection.GET_TEXT_WITH_STYLES); + final CharSequence after = getTextAfterCursorAndDetectLaggyConnection( + OPERATION_GET_WORD_RANGE_AT_CURSOR, + SLOW_INPUT_CONNECTION_ON_PARTIAL_RELOAD_MS, + NUM_CHARS_TO_GET_AFTER_CURSOR, + InputConnection.GET_TEXT_WITH_STYLES); + if (before == null || after == null) { + return null; + } + + // Going backward, find the first breaking point (separator) + int startIndexInBefore = before.length(); + while (startIndexInBefore > 0) { + final int codePoint = Character.codePointBefore(before, startIndexInBefore); + if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, scriptId)) { + break; + } + --startIndexInBefore; + if (Character.isSupplementaryCodePoint(codePoint)) { + --startIndexInBefore; + } + } + + // Find last word separator after the cursor + int endIndexInAfter = -1; + while (++endIndexInAfter < after.length()) { + final int codePoint = Character.codePointAt(after, endIndexInAfter); + if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, scriptId)) { + break; + } + if (Character.isSupplementaryCodePoint(codePoint)) { + ++endIndexInAfter; + } + } + + final boolean hasUrlSpans = + SpannableStringUtils.hasUrlSpans(before, startIndexInBefore, before.length()) + || SpannableStringUtils.hasUrlSpans(after, 0, endIndexInAfter); + // We don't use TextUtils#concat because it copies all spans without respect to their + // nature. If the text includes a PARAGRAPH span and it has been split, then + // TextUtils#concat will crash when it tries to concat both sides of it. + return new TextRange( + SpannableStringUtils.concatWithNonParagraphSuggestionSpansOnly(before, after), + startIndexInBefore, before.length() + endIndexInAfter, before.length(), + hasUrlSpans); + } + + public boolean isCursorTouchingWord(final SpacingAndPunctuations spacingAndPunctuations, + boolean checkTextAfter) { + if (checkTextAfter && isCursorFollowedByWordCharacter(spacingAndPunctuations)) { + // If what's after the cursor is a word character, then we're touching a word. + return true; + } + final String textBeforeCursor = mCommittedTextBeforeComposingText.toString(); + int indexOfCodePointInJavaChars = textBeforeCursor.length(); + int consideredCodePoint = 0 == indexOfCodePointInJavaChars ? Constants.NOT_A_CODE + : textBeforeCursor.codePointBefore(indexOfCodePointInJavaChars); + // Search for the first non word-connector char + if (spacingAndPunctuations.isWordConnector(consideredCodePoint)) { + indexOfCodePointInJavaChars -= Character.charCount(consideredCodePoint); + consideredCodePoint = 0 == indexOfCodePointInJavaChars ? Constants.NOT_A_CODE + : textBeforeCursor.codePointBefore(indexOfCodePointInJavaChars); + } + return !(Constants.NOT_A_CODE == consideredCodePoint + || spacingAndPunctuations.isWordSeparator(consideredCodePoint) + || spacingAndPunctuations.isWordConnector(consideredCodePoint)); + } + + public boolean isCursorFollowedByWordCharacter( + final SpacingAndPunctuations spacingAndPunctuations) { + final CharSequence after = getTextAfterCursor(1, 0); + if (TextUtils.isEmpty(after)) { + return false; + } + final int codePointAfterCursor = Character.codePointAt(after, 0); + if (spacingAndPunctuations.isWordSeparator(codePointAfterCursor) + || spacingAndPunctuations.isWordConnector(codePointAfterCursor)) { + return false; + } + return true; + } + + public void removeTrailingSpace() { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + final int codePointBeforeCursor = getCodePointBeforeCursor(); + if (Constants.CODE_SPACE == codePointBeforeCursor) { + deleteTextBeforeCursor(1); + } + } + + public boolean sameAsTextBeforeCursor(final CharSequence text) { + final CharSequence beforeText = getTextBeforeCursor(text.length(), 0); + return TextUtils.equals(text, beforeText); + } + + public boolean revertDoubleSpacePeriod(final SpacingAndPunctuations spacingAndPunctuations) { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + // Here we test whether we indeed have a period and a space before us. This should not + // be needed, but it's there just in case something went wrong. + final CharSequence textBeforeCursor = getTextBeforeCursor(2, 0); + if (!TextUtils.equals(spacingAndPunctuations.mSentenceSeparatorAndSpace, + textBeforeCursor)) { + // Theoretically we should not be coming here if there isn't ". " before the + // cursor, but the application may be changing the text while we are typing, so + // anything goes. We should not crash. + Log.d(TAG, "Tried to revert double-space combo but we didn't find \"" + + spacingAndPunctuations.mSentenceSeparatorAndSpace + + "\" just before the cursor."); + return false; + } + // Double-space results in ". ". A backspace to cancel this should result in a single + // space in the text field, so we replace ". " with a single space. + deleteTextBeforeCursor(2); + final String singleSpace = " "; + commitText(singleSpace, 1); + return true; + } + + public boolean revertSwapPunctuation() { + if (DEBUG_BATCH_NESTING) checkBatchEdit(); + // Here we test whether we indeed have a space and something else before us. This should not + // be needed, but it's there just in case something went wrong. + final CharSequence textBeforeCursor = getTextBeforeCursor(2, 0); + // NOTE: This does not work with surrogate pairs. Hopefully when the keyboard is able to + // enter surrogate pairs this code will have been removed. + if (TextUtils.isEmpty(textBeforeCursor) + || (Constants.CODE_SPACE != textBeforeCursor.charAt(1))) { + // We may only come here if the application is changing the text while we are typing. + // This is quite a broken case, but not logically impossible, so we shouldn't crash, + // but some debugging log may be in order. + Log.d(TAG, "Tried to revert a swap of punctuation but we didn't " + + "find a space just before the cursor."); + return false; + } + deleteTextBeforeCursor(2); + final String text = " " + textBeforeCursor.subSequence(0, 1); + commitText(text, 1); + return true; + } + + /** + * Heuristic to determine if this is an expected update of the cursor. + * + * Sometimes updates to the cursor position are late because of their asynchronous nature. + * This method tries to determine if this update is one, based on the values of the cursor + * position in the update, and the currently expected position of the cursor according to + * LatinIME's internal accounting. If this is not a belated expected update, then it should + * mean that the user moved the cursor explicitly. + * This is quite robust, but of course it's not perfect. In particular, it will fail in the + * case we get an update A, the user types in N characters so as to move the cursor to A+N but + * we don't get those, and then the user places the cursor between A and A+N, and we get only + * this update and not the ones in-between. This is almost impossible to achieve even trying + * very very hard. + * + * @param oldSelStart The value of the old selection in the update. + * @param newSelStart The value of the new selection in the update. + * @param oldSelEnd The value of the old selection end in the update. + * @param newSelEnd The value of the new selection end in the update. + * @return whether this is a belated expected update or not. + */ + public boolean isBelatedExpectedUpdate(final int oldSelStart, final int newSelStart, + final int oldSelEnd, final int newSelEnd) { + // This update is "belated" if we are expecting it. That is, mExpectedSelStart and + // mExpectedSelEnd match the new values that the TextView is updating TO. + if (mExpectedSelStart == newSelStart && mExpectedSelEnd == newSelEnd) return true; + // This update is not belated if mExpectedSelStart and mExpectedSelEnd match the old + // values, and one of newSelStart or newSelEnd is updated to a different value. In this + // case, it is likely that something other than the IME has moved the selection endpoint + // to the new value. + if (mExpectedSelStart == oldSelStart && mExpectedSelEnd == oldSelEnd + && (oldSelStart != newSelStart || oldSelEnd != newSelEnd)) return false; + // If neither of the above two cases hold, then the system may be having trouble keeping up + // with updates. If 1) the selection is a cursor, 2) newSelStart is between oldSelStart + // and mExpectedSelStart, and 3) newSelEnd is between oldSelEnd and mExpectedSelEnd, then + // assume a belated update. + return (newSelStart == newSelEnd) + && (newSelStart - oldSelStart) * (mExpectedSelStart - newSelStart) >= 0 + && (newSelEnd - oldSelEnd) * (mExpectedSelEnd - newSelEnd) >= 0; + } + + /** + * Looks at the text just before the cursor to find out if it looks like a URL. + * + * The weakest point here is, if we don't have enough text bufferized, we may fail to realize + * we are in URL situation, but other places in this class have the same limitation and it + * does not matter too much in the practice. + */ + public boolean textBeforeCursorLooksLikeURL() { + return StringUtils.lastPartLooksLikeURL(mCommittedTextBeforeComposingText); + } + + /** + * Looks at the text just before the cursor to find out if we are inside a double quote. + * + * As with #textBeforeCursorLooksLikeURL, this is dependent on how much text we have cached. + * However this won't be a concrete problem in most situations, as the cache is almost always + * long enough for this use. + */ + public boolean isInsideDoubleQuoteOrAfterDigit() { + return StringUtils.isInsideDoubleQuoteOrAfterDigit(mCommittedTextBeforeComposingText); + } + + /** + * Try to get the text from the editor to expose lies the framework may have been + * telling us. Concretely, when the device rotates and when the keyboard reopens in the same + * text field after having been closed with the back key, the frameworks tells us about where + * the cursor used to be initially in the editor at the time it first received the focus; this + * may be completely different from the place it is upon rotation. Since we don't have any + * means to get the real value, try at least to ask the text view for some characters and + * detect the most damaging cases: when the cursor position is declared to be much smaller + * than it really is. + */ + public void tryFixLyingCursorPosition() { + mIC = mParent.getCurrentInputConnection(); + final CharSequence textBeforeCursor = getTextBeforeCursor( + Constants.EDITOR_CONTENTS_CACHE_SIZE, 0); + final CharSequence selectedText = isConnected() ? mIC.getSelectedText(0 /* flags */) : null; + if (null == textBeforeCursor || + (!TextUtils.isEmpty(selectedText) && mExpectedSelEnd == mExpectedSelStart)) { + // If textBeforeCursor is null, we have no idea what kind of text field we have or if + // thinking about the "cursor position" actually makes any sense. In this case we + // remember a meaningless cursor position. Contrast this with an empty string, which is + // valid and should mean the cursor is at the start of the text. + // Also, if we expect we don't have a selection but we DO have non-empty selected text, + // then the framework lied to us about the cursor position. In this case, we should just + // revert to the most basic behavior possible for the next action (backspace in + // particular comes to mind), so we remember a meaningless cursor position which should + // result in degraded behavior from the next input. + // Interestingly, in either case, chances are any action the user takes next will result + // in a call to onUpdateSelection, which should set things right. + mExpectedSelStart = mExpectedSelEnd = Constants.NOT_A_CURSOR_POSITION; + } else { + final int textLength = textBeforeCursor.length(); + if (textLength < Constants.EDITOR_CONTENTS_CACHE_SIZE + && (textLength > mExpectedSelStart + || mExpectedSelStart < Constants.EDITOR_CONTENTS_CACHE_SIZE)) { + // It should not be possible to have only one of those variables be + // NOT_A_CURSOR_POSITION, so if they are equal, either the selection is zero-sized + // (simple cursor, no selection) or there is no cursor/we don't know its pos + final boolean wasEqual = mExpectedSelStart == mExpectedSelEnd; + mExpectedSelStart = textLength; + // We can't figure out the value of mLastSelectionEnd :( + // But at least if it's smaller than mLastSelectionStart something is wrong, + // and if they used to be equal we also don't want to make it look like there is a + // selection. + if (wasEqual || mExpectedSelStart > mExpectedSelEnd) { + mExpectedSelEnd = mExpectedSelStart; + } + } + } + } + + @Override + public boolean performPrivateCommand(final String action, final Bundle data) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return false; + } + return mIC.performPrivateCommand(action, data); + } + + public int getExpectedSelectionStart() { + return mExpectedSelStart; + } + + public int getExpectedSelectionEnd() { + return mExpectedSelEnd; + } + + /** + * @return whether there is a selection currently active. + */ + public boolean hasSelection() { + return mExpectedSelEnd != mExpectedSelStart; + } + + public boolean isCursorPositionKnown() { + return INVALID_CURSOR_POSITION != mExpectedSelStart; + } + + /** + * Work around a bug that was present before Jelly Bean upon rotation. + * + * Before Jelly Bean, there is a bug where setComposingRegion and other committing + * functions on the input connection get ignored until the cursor moves. This method works + * around the bug by wiggling the cursor first, which reactivates the connection and has + * the subsequent methods work, then restoring it to its original position. + * + * On platforms on which this method is not present, this is a no-op. + */ + public void maybeMoveTheCursorAroundAndRestoreToWorkaroundABug() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + if (mExpectedSelStart > 0) { + mIC.setSelection(mExpectedSelStart - 1, mExpectedSelStart - 1); + } else { + mIC.setSelection(mExpectedSelStart + 1, mExpectedSelStart + 1); + } + mIC.setSelection(mExpectedSelStart, mExpectedSelEnd); + } + } + + /** + * Requests the editor to call back {@link InputMethodManager#updateCursorAnchorInfo}. + * @param enableMonitor {@code true} to request the editor to call back the method whenever the + * cursor/anchor position is changed. + * @param requestImmediateCallback {@code true} to request the editor to call back the method + * as soon as possible to notify the current cursor/anchor position to the input method. + * @return {@code true} if the request is accepted. Returns {@code false} otherwise, which + * includes "not implemented" or "rejected" or "temporarily unavailable" or whatever which + * prevents the application from fulfilling the request. (TODO: Improve the API when it turns + * out that we actually need more detailed error codes) + */ + public boolean requestCursorUpdates(final boolean enableMonitor, + final boolean requestImmediateCallback) { + mIC = mParent.getCurrentInputConnection(); + if (!isConnected()) { + return false; + } + return InputConnectionCompatUtils.requestCursorUpdates( + mIC, enableMonitor, requestImmediateCallback); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/RichInputMethodManager.java b/java/src/org/kelar/inputmethod/latin/RichInputMethodManager.java new file mode 100644 index 000000000..f364ce982 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/RichInputMethodManager.java @@ -0,0 +1,612 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import static org.kelar.inputmethod.latin.common.Constants.Subtype.KEYBOARD_MODE; + +import android.content.Context; +import android.content.SharedPreferences; +import android.inputmethodservice.InputMethodService; +import android.os.AsyncTask; +import android.os.Build; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.compat.InputMethodManagerCompatWrapper; +import org.kelar.inputmethod.compat.InputMethodSubtypeCompatUtils; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils; +import org.kelar.inputmethod.latin.utils.LanguageOnSpacebarUtils; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Enrichment class for InputMethodManager to simplify interaction and add functionality. + */ +// non final for easy mocking. +public class RichInputMethodManager { + private static final String TAG = RichInputMethodManager.class.getSimpleName(); + private static final boolean DEBUG = false; + + private RichInputMethodManager() { + // This utility class is not publicly instantiable. + } + + private static final RichInputMethodManager sInstance = new RichInputMethodManager(); + + private Context mContext; + private InputMethodManagerCompatWrapper mImmWrapper; + private InputMethodInfoCache mInputMethodInfoCache; + private RichInputMethodSubtype mCurrentRichInputMethodSubtype; + private InputMethodInfo mShortcutInputMethodInfo; + private InputMethodSubtype mShortcutSubtype; + + private static final int INDEX_NOT_FOUND = -1; + + public static RichInputMethodManager getInstance() { + sInstance.checkInitialized(); + return sInstance; + } + + public static void init(final Context context) { + sInstance.initInternal(context); + } + + private boolean isInitialized() { + return mImmWrapper != null; + } + + private void checkInitialized() { + if (!isInitialized()) { + throw new RuntimeException(TAG + " is used before initialization"); + } + } + + private void initInternal(final Context context) { + if (isInitialized()) { + return; + } + mImmWrapper = new InputMethodManagerCompatWrapper(context); + mContext = context; + mInputMethodInfoCache = new InputMethodInfoCache( + mImmWrapper.mImm, context.getPackageName()); + + // Initialize additional subtypes. + SubtypeLocaleUtils.init(context); + final InputMethodSubtype[] additionalSubtypes = getAdditionalSubtypes(); + mImmWrapper.mImm.setAdditionalInputMethodSubtypes( + getInputMethodIdOfThisIme(), additionalSubtypes); + + // Initialize the current input method subtype and the shortcut IME. + refreshSubtypeCaches(); + } + + public InputMethodSubtype[] getAdditionalSubtypes() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext); + final String prefAdditionalSubtypes = Settings.readPrefAdditionalSubtypes( + prefs, mContext.getResources()); + return AdditionalSubtypeUtils.createAdditionalSubtypesArray(prefAdditionalSubtypes); + } + + public InputMethodManager getInputMethodManager() { + checkInitialized(); + return mImmWrapper.mImm; + } + + public List<InputMethodSubtype> getMyEnabledInputMethodSubtypeList( + boolean allowsImplicitlySelectedSubtypes) { + return getEnabledInputMethodSubtypeList( + getInputMethodInfoOfThisIme(), allowsImplicitlySelectedSubtypes); + } + + public boolean switchToNextInputMethod(final IBinder token, final boolean onlyCurrentIme) { + if (mImmWrapper.switchToNextInputMethod(token, onlyCurrentIme)) { + return true; + } + // Was not able to call {@link InputMethodManager#switchToNextInputMethodIBinder,boolean)} + // because the current device is running ICS or previous and lacks the API. + if (switchToNextInputSubtypeInThisIme(token, onlyCurrentIme)) { + return true; + } + return switchToNextInputMethodAndSubtype(token); + } + + private boolean switchToNextInputSubtypeInThisIme(final IBinder token, + final boolean onlyCurrentIme) { + final InputMethodManager imm = mImmWrapper.mImm; + final InputMethodSubtype currentSubtype = imm.getCurrentInputMethodSubtype(); + final List<InputMethodSubtype> enabledSubtypes = getMyEnabledInputMethodSubtypeList( + true /* allowsImplicitlySelectedSubtypes */); + final int currentIndex = getSubtypeIndexInList(currentSubtype, enabledSubtypes); + if (currentIndex == INDEX_NOT_FOUND) { + Log.w(TAG, "Can't find current subtype in enabled subtypes: subtype=" + + SubtypeLocaleUtils.getSubtypeNameForLogging(currentSubtype)); + return false; + } + final int nextIndex = (currentIndex + 1) % enabledSubtypes.size(); + if (nextIndex <= currentIndex && !onlyCurrentIme) { + // The current subtype is the last or only enabled one and it needs to switch to + // next IME. + return false; + } + final InputMethodSubtype nextSubtype = enabledSubtypes.get(nextIndex); + setInputMethodAndSubtype(token, nextSubtype); + return true; + } + + private boolean switchToNextInputMethodAndSubtype(final IBinder token) { + final InputMethodManager imm = mImmWrapper.mImm; + final List<InputMethodInfo> enabledImis = imm.getEnabledInputMethodList(); + final int currentIndex = getImiIndexInList(getInputMethodInfoOfThisIme(), enabledImis); + if (currentIndex == INDEX_NOT_FOUND) { + Log.w(TAG, "Can't find current IME in enabled IMEs: IME package=" + + getInputMethodInfoOfThisIme().getPackageName()); + return false; + } + final InputMethodInfo nextImi = getNextNonAuxiliaryIme(currentIndex, enabledImis); + final List<InputMethodSubtype> enabledSubtypes = getEnabledInputMethodSubtypeList(nextImi, + true /* allowsImplicitlySelectedSubtypes */); + if (enabledSubtypes.isEmpty()) { + // The next IME has no subtype. + imm.setInputMethod(token, nextImi.getId()); + return true; + } + final InputMethodSubtype firstSubtype = enabledSubtypes.get(0); + imm.setInputMethodAndSubtype(token, nextImi.getId(), firstSubtype); + return true; + } + + private static int getImiIndexInList(final InputMethodInfo inputMethodInfo, + final List<InputMethodInfo> imiList) { + final int count = imiList.size(); + for (int index = 0; index < count; index++) { + final InputMethodInfo imi = imiList.get(index); + if (imi.equals(inputMethodInfo)) { + return index; + } + } + return INDEX_NOT_FOUND; + } + + // This method mimics {@link InputMethodManager#switchToNextInputMethod(IBinder,boolean)}. + private static InputMethodInfo getNextNonAuxiliaryIme(final int currentIndex, + final List<InputMethodInfo> imiList) { + final int count = imiList.size(); + for (int i = 1; i < count; i++) { + final int nextIndex = (currentIndex + i) % count; + final InputMethodInfo nextImi = imiList.get(nextIndex); + if (!isAuxiliaryIme(nextImi)) { + return nextImi; + } + } + return imiList.get(currentIndex); + } + + // Copied from {@link InputMethodInfo}. See how auxiliary of IME is determined. + private static boolean isAuxiliaryIme(final InputMethodInfo imi) { + final int count = imi.getSubtypeCount(); + if (count == 0) { + return false; + } + for (int index = 0; index < count; index++) { + final InputMethodSubtype subtype = imi.getSubtypeAt(index); + if (!subtype.isAuxiliary()) { + return false; + } + } + return true; + } + + private static class InputMethodInfoCache { + private final InputMethodManager mImm; + private final String mImePackageName; + + private InputMethodInfo mCachedThisImeInfo; + private final HashMap<InputMethodInfo, List<InputMethodSubtype>> + mCachedSubtypeListWithImplicitlySelected; + private final HashMap<InputMethodInfo, List<InputMethodSubtype>> + mCachedSubtypeListOnlyExplicitlySelected; + + public InputMethodInfoCache(final InputMethodManager imm, final String imePackageName) { + mImm = imm; + mImePackageName = imePackageName; + mCachedSubtypeListWithImplicitlySelected = new HashMap<>(); + mCachedSubtypeListOnlyExplicitlySelected = new HashMap<>(); + } + + public synchronized InputMethodInfo getInputMethodOfThisIme() { + if (mCachedThisImeInfo != null) { + return mCachedThisImeInfo; + } + for (final InputMethodInfo imi : mImm.getInputMethodList()) { + if (imi.getPackageName().equals(mImePackageName)) { + mCachedThisImeInfo = imi; + return imi; + } + } + throw new RuntimeException("Input method id for " + mImePackageName + " not found."); + } + + public synchronized List<InputMethodSubtype> getEnabledInputMethodSubtypeList( + final InputMethodInfo imi, final boolean allowsImplicitlySelectedSubtypes) { + final HashMap<InputMethodInfo, List<InputMethodSubtype>> cache = + allowsImplicitlySelectedSubtypes + ? mCachedSubtypeListWithImplicitlySelected + : mCachedSubtypeListOnlyExplicitlySelected; + final List<InputMethodSubtype> cachedList = cache.get(imi); + if (cachedList != null) { + return cachedList; + } + final List<InputMethodSubtype> result = mImm.getEnabledInputMethodSubtypeList( + imi, allowsImplicitlySelectedSubtypes); + cache.put(imi, result); + return result; + } + + public synchronized void clear() { + mCachedThisImeInfo = null; + mCachedSubtypeListWithImplicitlySelected.clear(); + mCachedSubtypeListOnlyExplicitlySelected.clear(); + } + } + + public InputMethodInfo getInputMethodInfoOfThisIme() { + return mInputMethodInfoCache.getInputMethodOfThisIme(); + } + + public String getInputMethodIdOfThisIme() { + return getInputMethodInfoOfThisIme().getId(); + } + + public boolean checkIfSubtypeBelongsToThisImeAndEnabled(final InputMethodSubtype subtype) { + return checkIfSubtypeBelongsToList(subtype, + getEnabledInputMethodSubtypeList( + getInputMethodInfoOfThisIme(), + true /* allowsImplicitlySelectedSubtypes */)); + } + + public boolean checkIfSubtypeBelongsToThisImeAndImplicitlyEnabled( + final InputMethodSubtype subtype) { + final boolean subtypeEnabled = checkIfSubtypeBelongsToThisImeAndEnabled(subtype); + final boolean subtypeExplicitlyEnabled = checkIfSubtypeBelongsToList(subtype, + getMyEnabledInputMethodSubtypeList(false /* allowsImplicitlySelectedSubtypes */)); + return subtypeEnabled && !subtypeExplicitlyEnabled; + } + + private static boolean checkIfSubtypeBelongsToList(final InputMethodSubtype subtype, + final List<InputMethodSubtype> subtypes) { + return getSubtypeIndexInList(subtype, subtypes) != INDEX_NOT_FOUND; + } + + private static int getSubtypeIndexInList(final InputMethodSubtype subtype, + final List<InputMethodSubtype> subtypes) { + final int count = subtypes.size(); + for (int index = 0; index < count; index++) { + final InputMethodSubtype ims = subtypes.get(index); + if (ims.equals(subtype)) { + return index; + } + } + return INDEX_NOT_FOUND; + } + + public void onSubtypeChanged(@Nonnull final InputMethodSubtype newSubtype) { + updateCurrentSubtype(newSubtype); + updateShortcutIme(); + if (DEBUG) { + Log.w(TAG, "onSubtypeChanged: " + mCurrentRichInputMethodSubtype.getNameForLogging()); + } + } + + private static RichInputMethodSubtype sForcedSubtypeForTesting = null; + + @UsedForTesting + static void forceSubtype(@Nonnull final InputMethodSubtype subtype) { + sForcedSubtypeForTesting = RichInputMethodSubtype.getRichInputMethodSubtype(subtype); + } + + @Nonnull + public Locale getCurrentSubtypeLocale() { + if (null != sForcedSubtypeForTesting) { + return sForcedSubtypeForTesting.getLocale(); + } + return getCurrentSubtype().getLocale(); + } + + @Nonnull + public RichInputMethodSubtype getCurrentSubtype() { + if (null != sForcedSubtypeForTesting) { + return sForcedSubtypeForTesting; + } + return mCurrentRichInputMethodSubtype; + } + + + public String getCombiningRulesExtraValueOfCurrentSubtype() { + return SubtypeLocaleUtils.getCombiningRulesExtraValue(getCurrentSubtype().getRawSubtype()); + } + + public boolean hasMultipleEnabledIMEsOrSubtypes(final boolean shouldIncludeAuxiliarySubtypes) { + final List<InputMethodInfo> enabledImis = mImmWrapper.mImm.getEnabledInputMethodList(); + return hasMultipleEnabledSubtypes(shouldIncludeAuxiliarySubtypes, enabledImis); + } + + public boolean hasMultipleEnabledSubtypesInThisIme( + final boolean shouldIncludeAuxiliarySubtypes) { + final List<InputMethodInfo> imiList = Collections.singletonList( + getInputMethodInfoOfThisIme()); + return hasMultipleEnabledSubtypes(shouldIncludeAuxiliarySubtypes, imiList); + } + + private boolean hasMultipleEnabledSubtypes(final boolean shouldIncludeAuxiliarySubtypes, + final List<InputMethodInfo> imiList) { + // Number of the filtered IMEs + int filteredImisCount = 0; + + for (InputMethodInfo imi : imiList) { + // We can return true immediately after we find two or more filtered IMEs. + if (filteredImisCount > 1) return true; + final List<InputMethodSubtype> subtypes = getEnabledInputMethodSubtypeList(imi, true); + // IMEs that have no subtypes should be counted. + if (subtypes.isEmpty()) { + ++filteredImisCount; + continue; + } + + int auxCount = 0; + for (InputMethodSubtype subtype : subtypes) { + if (subtype.isAuxiliary()) { + ++auxCount; + } + } + final int nonAuxCount = subtypes.size() - auxCount; + + // IMEs that have one or more non-auxiliary subtypes should be counted. + // If shouldIncludeAuxiliarySubtypes is true, IMEs that have two or more auxiliary + // subtypes should be counted as well. + if (nonAuxCount > 0 || (shouldIncludeAuxiliarySubtypes && auxCount > 1)) { + ++filteredImisCount; + } + } + + if (filteredImisCount > 1) { + return true; + } + final List<InputMethodSubtype> subtypes = getMyEnabledInputMethodSubtypeList(true); + int keyboardCount = 0; + // imm.getEnabledInputMethodSubtypeList(null, true) will return the current IME's + // both explicitly and implicitly enabled input method subtype. + // (The current IME should be LatinIME.) + for (InputMethodSubtype subtype : subtypes) { + if (KEYBOARD_MODE.equals(subtype.getMode())) { + ++keyboardCount; + } + } + return keyboardCount > 1; + } + + public InputMethodSubtype findSubtypeByLocaleAndKeyboardLayoutSet(final String localeString, + final String keyboardLayoutSetName) { + final InputMethodInfo myImi = getInputMethodInfoOfThisIme(); + final int count = myImi.getSubtypeCount(); + for (int i = 0; i < count; i++) { + final InputMethodSubtype subtype = myImi.getSubtypeAt(i); + final String layoutName = SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype); + if (localeString.equals(subtype.getLocale()) + && keyboardLayoutSetName.equals(layoutName)) { + return subtype; + } + } + return null; + } + + public InputMethodSubtype findSubtypeByLocale(final Locale locale) { + // Find the best subtype based on a straightforward matching algorithm. + // TODO: Use LocaleList#getFirstMatch() instead. + final List<InputMethodSubtype> subtypes = + getMyEnabledInputMethodSubtypeList(true /* allowsImplicitlySelectedSubtypes */); + final int count = subtypes.size(); + for (int i = 0; i < count; ++i) { + final InputMethodSubtype subtype = subtypes.get(i); + final Locale subtypeLocale = InputMethodSubtypeCompatUtils.getLocaleObject(subtype); + if (subtypeLocale.equals(locale)) { + return subtype; + } + } + for (int i = 0; i < count; ++i) { + final InputMethodSubtype subtype = subtypes.get(i); + final Locale subtypeLocale = InputMethodSubtypeCompatUtils.getLocaleObject(subtype); + if (subtypeLocale.getLanguage().equals(locale.getLanguage()) && + subtypeLocale.getCountry().equals(locale.getCountry()) && + subtypeLocale.getVariant().equals(locale.getVariant())) { + return subtype; + } + } + for (int i = 0; i < count; ++i) { + final InputMethodSubtype subtype = subtypes.get(i); + final Locale subtypeLocale = InputMethodSubtypeCompatUtils.getLocaleObject(subtype); + if (subtypeLocale.getLanguage().equals(locale.getLanguage()) && + subtypeLocale.getCountry().equals(locale.getCountry())) { + return subtype; + } + } + for (int i = 0; i < count; ++i) { + final InputMethodSubtype subtype = subtypes.get(i); + final Locale subtypeLocale = InputMethodSubtypeCompatUtils.getLocaleObject(subtype); + if (subtypeLocale.getLanguage().equals(locale.getLanguage())) { + return subtype; + } + } + return null; + } + + public void setInputMethodAndSubtype(final IBinder token, final InputMethodSubtype subtype) { + mImmWrapper.mImm.setInputMethodAndSubtype( + token, getInputMethodIdOfThisIme(), subtype); + } + + public void setAdditionalInputMethodSubtypes(final InputMethodSubtype[] subtypes) { + mImmWrapper.mImm.setAdditionalInputMethodSubtypes( + getInputMethodIdOfThisIme(), subtypes); + // Clear the cache so that we go read the {@link InputMethodInfo} of this IME and list of + // subtypes again next time. + refreshSubtypeCaches(); + } + + private List<InputMethodSubtype> getEnabledInputMethodSubtypeList(final InputMethodInfo imi, + final boolean allowsImplicitlySelectedSubtypes) { + return mInputMethodInfoCache.getEnabledInputMethodSubtypeList( + imi, allowsImplicitlySelectedSubtypes); + } + + public void refreshSubtypeCaches() { + mInputMethodInfoCache.clear(); + updateCurrentSubtype(mImmWrapper.mImm.getCurrentInputMethodSubtype()); + updateShortcutIme(); + } + + public boolean shouldOfferSwitchingToNextInputMethod(final IBinder binder, + boolean defaultValue) { + // Use the default value instead on Jelly Bean MR2 and previous where + // {@link InputMethodManager#shouldOfferSwitchingToNextInputMethod} isn't yet available + // and on KitKat where the API is still just a stub to return true always. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return defaultValue; + } + return mImmWrapper.shouldOfferSwitchingToNextInputMethod(binder); + } + + public boolean isSystemLocaleSameAsLocaleOfAllEnabledSubtypesOfEnabledImes() { + final Locale systemLocale = mContext.getResources().getConfiguration().locale; + final Set<InputMethodSubtype> enabledSubtypesOfEnabledImes = new HashSet<>(); + final InputMethodManager inputMethodManager = getInputMethodManager(); + final List<InputMethodInfo> enabledInputMethodInfoList = + inputMethodManager.getEnabledInputMethodList(); + for (final InputMethodInfo info : enabledInputMethodInfoList) { + final List<InputMethodSubtype> enabledSubtypes = + inputMethodManager.getEnabledInputMethodSubtypeList( + info, true /* allowsImplicitlySelectedSubtypes */); + if (enabledSubtypes.isEmpty()) { + // An IME with no subtypes is found. + return false; + } + enabledSubtypesOfEnabledImes.addAll(enabledSubtypes); + } + for (final InputMethodSubtype subtype : enabledSubtypesOfEnabledImes) { + if (!subtype.isAuxiliary() && !subtype.getLocale().isEmpty() + && !systemLocale.equals(SubtypeLocaleUtils.getSubtypeLocale(subtype))) { + return false; + } + } + return true; + } + + private void updateCurrentSubtype(@Nullable final InputMethodSubtype subtype) { + mCurrentRichInputMethodSubtype = RichInputMethodSubtype.getRichInputMethodSubtype(subtype); + } + + private void updateShortcutIme() { + if (DEBUG) { + Log.d(TAG, "Update shortcut IME from : " + + (mShortcutInputMethodInfo == null + ? "<null>" : mShortcutInputMethodInfo.getId()) + ", " + + (mShortcutSubtype == null ? "<null>" : ( + mShortcutSubtype.getLocale() + ", " + mShortcutSubtype.getMode()))); + } + final RichInputMethodSubtype richSubtype = mCurrentRichInputMethodSubtype; + final boolean implicitlyEnabledSubtype = checkIfSubtypeBelongsToThisImeAndImplicitlyEnabled( + richSubtype.getRawSubtype()); + final Locale systemLocale = mContext.getResources().getConfiguration().locale; + LanguageOnSpacebarUtils.onSubtypeChanged( + richSubtype, implicitlyEnabledSubtype, systemLocale); + LanguageOnSpacebarUtils.setEnabledSubtypes(getMyEnabledInputMethodSubtypeList( + true /* allowsImplicitlySelectedSubtypes */)); + + // TODO: Update an icon for shortcut IME + final Map<InputMethodInfo, List<InputMethodSubtype>> shortcuts = + getInputMethodManager().getShortcutInputMethodsAndSubtypes(); + mShortcutInputMethodInfo = null; + mShortcutSubtype = null; + for (final InputMethodInfo imi : shortcuts.keySet()) { + final List<InputMethodSubtype> subtypes = shortcuts.get(imi); + // TODO: Returns the first found IMI for now. Should handle all shortcuts as + // appropriate. + mShortcutInputMethodInfo = imi; + // TODO: Pick up the first found subtype for now. Should handle all subtypes + // as appropriate. + mShortcutSubtype = subtypes.size() > 0 ? subtypes.get(0) : null; + break; + } + if (DEBUG) { + Log.d(TAG, "Update shortcut IME to : " + + (mShortcutInputMethodInfo == null + ? "<null>" : mShortcutInputMethodInfo.getId()) + ", " + + (mShortcutSubtype == null ? "<null>" : ( + mShortcutSubtype.getLocale() + ", " + mShortcutSubtype.getMode()))); + } + } + + public void switchToShortcutIme(final InputMethodService context) { + if (mShortcutInputMethodInfo == null) { + return; + } + + final String imiId = mShortcutInputMethodInfo.getId(); + switchToTargetIME(imiId, mShortcutSubtype, context); + } + + private void switchToTargetIME(final String imiId, final InputMethodSubtype subtype, + final InputMethodService context) { + final IBinder token = context.getWindow().getWindow().getAttributes().token; + if (token == null) { + return; + } + final InputMethodManager imm = getInputMethodManager(); + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + imm.setInputMethodAndSubtype(token, imiId, subtype); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + public boolean isShortcutImeReady() { + if (mShortcutInputMethodInfo == null) { + return false; + } + if (mShortcutSubtype == null) { + return true; + } + return true; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/RichInputMethodSubtype.java b/java/src/org/kelar/inputmethod/latin/RichInputMethodSubtype.java new file mode 100644 index 000000000..d0502ddff --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/RichInputMethodSubtype.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import static org.kelar.inputmethod.latin.common.Constants.Subtype.KEYBOARD_MODE; + +import android.os.Build; +import android.util.Log; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.compat.InputMethodSubtypeCompatUtils; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.util.HashMap; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Enrichment class for InputMethodSubtype to enable concurrent multi-lingual input. + * + * Right now, this returns the extra value of its primary subtype. + */ +// non final for easy mocking. +public class RichInputMethodSubtype { + private static final String TAG = RichInputMethodSubtype.class.getSimpleName(); + + private static final HashMap<Locale, Locale> sLocaleMap = initializeLocaleMap(); + private static final HashMap<Locale, Locale> initializeLocaleMap() { + final HashMap<Locale, Locale> map = new HashMap<>(); + if (BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Locale#forLanguageTag is available on API Level 21+. + // TODO: Remove this workaround once when we become able to deal with "sr-Latn". + map.put(Locale.forLanguageTag("sr-Latn"), new Locale("sr_ZZ")); + } + return map; + } + + @Nonnull + private final InputMethodSubtype mSubtype; + @Nonnull + private final Locale mLocale; + @Nonnull + private final Locale mOriginalLocale; + + public RichInputMethodSubtype(@Nonnull final InputMethodSubtype subtype) { + mSubtype = subtype; + mOriginalLocale = InputMethodSubtypeCompatUtils.getLocaleObject(mSubtype); + final Locale mappedLocale = sLocaleMap.get(mOriginalLocale); + mLocale = mappedLocale != null ? mappedLocale : mOriginalLocale; + } + + // Extra values are determined by the primary subtype. This is probably right, but + // we may have to revisit this later. + public String getExtraValueOf(@Nonnull final String key) { + return mSubtype.getExtraValueOf(key); + } + + // The mode is also determined by the primary subtype. + public String getMode() { + return mSubtype.getMode(); + } + + public boolean isNoLanguage() { + return SubtypeLocaleUtils.NO_LANGUAGE.equals(mSubtype.getLocale()); + } + + public String getNameForLogging() { + return toString(); + } + + // InputMethodSubtype's display name for spacebar text in its locale. + // isAdditionalSubtype (T=true, F=false) + // locale layout | Middle Full + // ------ ------- - --------- ---------------------- + // en_US qwerty F English English (US) exception + // en_GB qwerty F English English (UK) exception + // es_US spanish F Español Español (EE.UU.) exception + // fr azerty F Français Français + // fr_CA qwerty F Français Français (Canada) + // fr_CH swiss F Français Français (Suisse) + // de qwertz F Deutsch Deutsch + // de_CH swiss T Deutsch Deutsch (Schweiz) + // zz qwerty F QWERTY QWERTY + // fr qwertz T Français Français + // de qwerty T Deutsch Deutsch + // en_US azerty T English English (US) + // zz azerty T AZERTY AZERTY + // Get the RichInputMethodSubtype's full display name in its locale. + @Nonnull + public String getFullDisplayName() { + if (isNoLanguage()) { + return SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(mSubtype); + } + return SubtypeLocaleUtils.getSubtypeLocaleDisplayName(mSubtype.getLocale()); + } + + // Get the RichInputMethodSubtype's middle display name in its locale. + @Nonnull + public String getMiddleDisplayName() { + if (isNoLanguage()) { + return SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(mSubtype); + } + return SubtypeLocaleUtils.getSubtypeLanguageDisplayName(mSubtype.getLocale()); + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof RichInputMethodSubtype)) { + return false; + } + final RichInputMethodSubtype other = (RichInputMethodSubtype)o; + return mSubtype.equals(other.mSubtype) && mLocale.equals(other.mLocale); + } + + @Override + public int hashCode() { + return mSubtype.hashCode() + mLocale.hashCode(); + } + + @Override + public String toString() { + return "Multi-lingual subtype: " + mSubtype + ", " + mLocale; + } + + @Nonnull + public Locale getLocale() { + return mLocale; + } + + @Nonnull + public Locale getOriginalLocale() { + return mOriginalLocale; + } + + public boolean isRtlSubtype() { + // The subtype is considered RTL if the language of the main subtype is RTL. + return LocaleUtils.isRtlLanguage(mLocale); + } + + // TODO: remove this method + @Nonnull + public InputMethodSubtype getRawSubtype() { return mSubtype; } + + @Nonnull + public String getKeyboardLayoutSetName() { + return SubtypeLocaleUtils.getKeyboardLayoutSetName(mSubtype); + } + + public static RichInputMethodSubtype getRichInputMethodSubtype( + @Nullable final InputMethodSubtype subtype) { + if (subtype == null) { + return getNoLanguageSubtype(); + } else { + return new RichInputMethodSubtype(subtype); + } + } + + // Placeholer for no language QWERTY subtype. See {@link R.xml.method}. + private static final int SUBTYPE_ID_OF_PLACEHOLDER_NO_LANGUAGE_SUBTYPE = 0xdde0bfd3; + private static final String EXTRA_VALUE_OF_PLACEHOLDER_NO_LANGUAGE_SUBTYPE = + "KeyboardLayoutSet=" + SubtypeLocaleUtils.QWERTY + + "," + Constants.Subtype.ExtraValue.ASCII_CAPABLE + + "," + Constants.Subtype.ExtraValue.ENABLED_WHEN_DEFAULT_IS_NOT_ASCII_CAPABLE + + "," + Constants.Subtype.ExtraValue.EMOJI_CAPABLE; + @Nonnull + private static final RichInputMethodSubtype PLACEHOLDER_NO_LANGUAGE_SUBTYPE = + new RichInputMethodSubtype(InputMethodSubtypeCompatUtils.newInputMethodSubtype( + R.string.subtype_no_language_qwerty, R.drawable.ic_ime_switcher_dark, + SubtypeLocaleUtils.NO_LANGUAGE, KEYBOARD_MODE, + EXTRA_VALUE_OF_PLACEHOLDER_NO_LANGUAGE_SUBTYPE, + false /* isAuxiliary */, false /* overridesImplicitlyEnabledSubtype */, + SUBTYPE_ID_OF_PLACEHOLDER_NO_LANGUAGE_SUBTYPE)); + // Caveat: We probably should remove this when we add an Emoji subtype in {@link R.xml.method}. + // Placeholder Emoji subtype. See {@link R.xml.method}. + private static final int SUBTYPE_ID_OF_PLACEHOLDER_EMOJI_SUBTYPE = 0xd78b2ed0; + private static final String EXTRA_VALUE_OF_PLACEHOLDER_EMOJI_SUBTYPE = + "KeyboardLayoutSet=" + SubtypeLocaleUtils.EMOJI + + "," + Constants.Subtype.ExtraValue.EMOJI_CAPABLE; + @Nonnull + private static final RichInputMethodSubtype PLACEHOLDER_EMOJI_SUBTYPE = new RichInputMethodSubtype( + InputMethodSubtypeCompatUtils.newInputMethodSubtype( + R.string.subtype_emoji, R.drawable.ic_ime_switcher_dark, + SubtypeLocaleUtils.NO_LANGUAGE, KEYBOARD_MODE, + EXTRA_VALUE_OF_PLACEHOLDER_EMOJI_SUBTYPE, + false /* isAuxiliary */, false /* overridesImplicitlyEnabledSubtype */, + SUBTYPE_ID_OF_PLACEHOLDER_EMOJI_SUBTYPE)); + private static RichInputMethodSubtype sNoLanguageSubtype; + private static RichInputMethodSubtype sEmojiSubtype; + + @Nonnull + public static RichInputMethodSubtype getNoLanguageSubtype() { + RichInputMethodSubtype noLanguageSubtype = sNoLanguageSubtype; + if (noLanguageSubtype == null) { + final InputMethodSubtype rawNoLanguageSubtype = RichInputMethodManager.getInstance() + .findSubtypeByLocaleAndKeyboardLayoutSet( + SubtypeLocaleUtils.NO_LANGUAGE, SubtypeLocaleUtils.QWERTY); + if (rawNoLanguageSubtype != null) { + noLanguageSubtype = new RichInputMethodSubtype(rawNoLanguageSubtype); + } + } + if (noLanguageSubtype != null) { + sNoLanguageSubtype = noLanguageSubtype; + return noLanguageSubtype; + } + Log.w(TAG, "Can't find any language with QWERTY subtype"); + Log.w(TAG, "No input method subtype found; returning placeholder subtype: " + + PLACEHOLDER_NO_LANGUAGE_SUBTYPE); + return PLACEHOLDER_NO_LANGUAGE_SUBTYPE; + } + + @Nonnull + public static RichInputMethodSubtype getEmojiSubtype() { + RichInputMethodSubtype emojiSubtype = sEmojiSubtype; + if (emojiSubtype == null) { + final InputMethodSubtype rawEmojiSubtype = RichInputMethodManager.getInstance() + .findSubtypeByLocaleAndKeyboardLayoutSet( + SubtypeLocaleUtils.NO_LANGUAGE, SubtypeLocaleUtils.EMOJI); + if (rawEmojiSubtype != null) { + emojiSubtype = new RichInputMethodSubtype(rawEmojiSubtype); + } + } + if (emojiSubtype != null) { + sEmojiSubtype = emojiSubtype; + return emojiSubtype; + } + Log.w(TAG, "Can't find emoji subtype"); + Log.w(TAG, "No input method subtype found; returning placeholder subtype: " + + PLACEHOLDER_EMOJI_SUBTYPE); + return PLACEHOLDER_EMOJI_SUBTYPE; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/Suggest.java b/java/src/org/kelar/inputmethod/latin/Suggest.java new file mode 100644 index 000000000..7023b4db3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/Suggest.java @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import android.text.TextUtils; + +import static org.kelar.inputmethod.latin.define.DecoderSpecificConstants.SHOULD_AUTO_CORRECT_USING_NON_WHITE_LISTED_SUGGESTION; +import static org.kelar.inputmethod.latin.define.DecoderSpecificConstants.SHOULD_REMOVE_PREVIOUSLY_REJECTED_SUGGESTION; + +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.AutoCorrectionUtils; +import org.kelar.inputmethod.latin.utils.BinaryDictionaryUtils; +import org.kelar.inputmethod.latin.utils.SuggestionResults; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; + +import javax.annotation.Nonnull; + +/** + * This class loads a dictionary and provides a list of suggestions for a given sequence of + * characters. This includes corrections and completions. + */ +public final class Suggest { + public static final String TAG = Suggest.class.getSimpleName(); + + // Session id for + // {@link #getSuggestedWords(WordComposer,String,ProximityInfo,boolean,int)}. + // We are sharing the same ID between typing and gesture to save RAM footprint. + public static final int SESSION_ID_TYPING = 0; + public static final int SESSION_ID_GESTURE = 0; + + // Close to -2**31 + private static final int SUPPRESS_SUGGEST_THRESHOLD = -2000000000; + + private static final boolean DBG = DebugFlags.DEBUG_ENABLED; + private final DictionaryFacilitator mDictionaryFacilitator; + + private static final int MAXIMUM_AUTO_CORRECT_LENGTH_FOR_GERMAN = 12; + private static final HashMap<String, Integer> sLanguageToMaximumAutoCorrectionWithSpaceLength = + new HashMap<>(); + static { + // TODO: should we add Finnish here? + // TODO: This should not be hardcoded here but be written in the dictionary header + sLanguageToMaximumAutoCorrectionWithSpaceLength.put(Locale.GERMAN.getLanguage(), + MAXIMUM_AUTO_CORRECT_LENGTH_FOR_GERMAN); + } + + private float mAutoCorrectionThreshold; + private float mPlausibilityThreshold; + + public Suggest(final DictionaryFacilitator dictionaryFacilitator) { + mDictionaryFacilitator = dictionaryFacilitator; + } + + /** + * Set the normalized-score threshold for a suggestion to be considered strong enough that we + * will auto-correct to this. + * @param threshold the threshold + */ + public void setAutoCorrectionThreshold(final float threshold) { + mAutoCorrectionThreshold = threshold; + } + + /** + * Set the normalized-score threshold for what we consider a "plausible" suggestion, in + * the same dimension as the auto-correction threshold. + * @param threshold the threshold + */ + public void setPlausibilityThreshold(final float threshold) { + mPlausibilityThreshold = threshold; + } + + public interface OnGetSuggestedWordsCallback { + public void onGetSuggestedWords(final SuggestedWords suggestedWords); + } + + public void getSuggestedWords(final WordComposer wordComposer, + final NgramContext ngramContext, final Keyboard keyboard, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final boolean isCorrectionEnabled, final int inputStyle, final int sequenceNumber, + final OnGetSuggestedWordsCallback callback) { + if (wordComposer.isBatchMode()) { + getSuggestedWordsForBatchInput(wordComposer, ngramContext, keyboard, + settingsValuesForSuggestion, inputStyle, sequenceNumber, callback); + } else { + getSuggestedWordsForNonBatchInput(wordComposer, ngramContext, keyboard, + settingsValuesForSuggestion, inputStyle, isCorrectionEnabled, + sequenceNumber, callback); + } + } + + private static ArrayList<SuggestedWordInfo> getTransformedSuggestedWordInfoList( + final WordComposer wordComposer, final SuggestionResults results, + final int trailingSingleQuotesCount, final Locale defaultLocale) { + final boolean shouldMakeSuggestionsAllUpperCase = wordComposer.isAllUpperCase() + && !wordComposer.isResumed(); + final boolean isOnlyFirstCharCapitalized = + wordComposer.isOrWillBeOnlyFirstCharCapitalized(); + + final ArrayList<SuggestedWordInfo> suggestionsContainer = new ArrayList<>(results); + final int suggestionsCount = suggestionsContainer.size(); + if (isOnlyFirstCharCapitalized || shouldMakeSuggestionsAllUpperCase + || 0 != trailingSingleQuotesCount) { + for (int i = 0; i < suggestionsCount; ++i) { + final SuggestedWordInfo wordInfo = suggestionsContainer.get(i); + final Locale wordLocale = wordInfo.mSourceDict.mLocale; + final SuggestedWordInfo transformedWordInfo = getTransformedSuggestedWordInfo( + wordInfo, null == wordLocale ? defaultLocale : wordLocale, + shouldMakeSuggestionsAllUpperCase, isOnlyFirstCharCapitalized, + trailingSingleQuotesCount); + suggestionsContainer.set(i, transformedWordInfo); + } + } + return suggestionsContainer; + } + + private static SuggestedWordInfo getWhitelistedWordInfoOrNull( + @Nonnull final ArrayList<SuggestedWordInfo> suggestions) { + if (suggestions.isEmpty()) { + return null; + } + final SuggestedWordInfo firstSuggestedWordInfo = suggestions.get(0); + if (!firstSuggestedWordInfo.isKindOf(SuggestedWordInfo.KIND_WHITELIST)) { + return null; + } + return firstSuggestedWordInfo; + } + + // Retrieves suggestions for non-batch input (typing, recorrection, predictions...) + // and calls the callback function with the suggestions. + private void getSuggestedWordsForNonBatchInput(final WordComposer wordComposer, + final NgramContext ngramContext, final Keyboard keyboard, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int inputStyleIfNotPrediction, final boolean isCorrectionEnabled, + final int sequenceNumber, final OnGetSuggestedWordsCallback callback) { + final String typedWordString = wordComposer.getTypedWord(); + final int trailingSingleQuotesCount = + StringUtils.getTrailingSingleQuotesCount(typedWordString); + final String consideredWord = trailingSingleQuotesCount > 0 + ? typedWordString.substring(0, typedWordString.length() - trailingSingleQuotesCount) + : typedWordString; + + final SuggestionResults suggestionResults = mDictionaryFacilitator.getSuggestionResults( + wordComposer.getComposedDataSnapshot(), ngramContext, keyboard, + settingsValuesForSuggestion, SESSION_ID_TYPING, inputStyleIfNotPrediction); + final Locale locale = mDictionaryFacilitator.getLocale(); + final ArrayList<SuggestedWordInfo> suggestionsContainer = + getTransformedSuggestedWordInfoList(wordComposer, suggestionResults, + trailingSingleQuotesCount, locale); + + boolean foundInDictionary = false; + Dictionary sourceDictionaryOfRemovedWord = null; + for (final SuggestedWordInfo info : suggestionsContainer) { + // Search for the best dictionary, defined as the first one with the highest match + // quality we can find. + if (!foundInDictionary && typedWordString.equals(info.mWord)) { + // Use this source if the old match had lower quality than this match + sourceDictionaryOfRemovedWord = info.mSourceDict; + foundInDictionary = true; + break; + } + } + + final int firstOcurrenceOfTypedWordInSuggestions = + SuggestedWordInfo.removeDups(typedWordString, suggestionsContainer); + + final SuggestedWordInfo whitelistedWordInfo = + getWhitelistedWordInfoOrNull(suggestionsContainer); + final String whitelistedWord = whitelistedWordInfo == null + ? null : whitelistedWordInfo.mWord; + final boolean resultsArePredictions = !wordComposer.isComposingWord(); + + // We allow auto-correction if whitelisting is not required or the word is whitelisted, + // or if the word had more than one char and was not suggested. + final boolean allowsToBeAutoCorrected = + (SHOULD_AUTO_CORRECT_USING_NON_WHITE_LISTED_SUGGESTION || whitelistedWord != null) + || (consideredWord.length() > 1 && (sourceDictionaryOfRemovedWord == null)); + + final boolean hasAutoCorrection; + // If correction is not enabled, we never auto-correct. This is for example for when + // the setting "Auto-correction" is "off": we still suggest, but we don't auto-correct. + if (!isCorrectionEnabled + // If the word does not allow to be auto-corrected, then we don't auto-correct. + || !allowsToBeAutoCorrected + // If we are doing prediction, then we never auto-correct of course + || resultsArePredictions + // If we don't have suggestion results, we can't evaluate the first suggestion + // for auto-correction + || suggestionResults.isEmpty() + // If the word has digits, we never auto-correct because it's likely the word + // was type with a lot of care + || wordComposer.hasDigits() + // If the word is mostly caps, we never auto-correct because this is almost + // certainly intentional (and careful input) + || wordComposer.isMostlyCaps() + // We never auto-correct when suggestions are resumed because it would be unexpected + || wordComposer.isResumed() + // If we don't have a main dictionary, we never want to auto-correct. The reason + // for this is, the user may have a contact whose name happens to match a valid + // word in their language, and it will unexpectedly auto-correct. For example, if + // the user types in English with no dictionary and has a "Will" in their contact + // list, "will" would always auto-correct to "Will" which is unwanted. Hence, no + // main dict => no auto-correct. Also, it would probably get obnoxious quickly. + // TODO: now that we have personalization, we may want to re-evaluate this decision + || !mDictionaryFacilitator.hasAtLeastOneInitializedMainDictionary() + // If the first suggestion is a shortcut we never auto-correct to it, regardless + // of how strong it is (allowlist entries are not KIND_SHORTCUT but KIND_WHITELIST). + // TODO: we may want to have shortcut-only entries auto-correct in the future. + || suggestionResults.first().isKindOf(SuggestedWordInfo.KIND_SHORTCUT)) { + hasAutoCorrection = false; + } else { + final SuggestedWordInfo firstSuggestion = suggestionResults.first(); + if (suggestionResults.mFirstSuggestionExceedsConfidenceThreshold + && firstOcurrenceOfTypedWordInSuggestions != 0) { + hasAutoCorrection = true; + } else if (!AutoCorrectionUtils.suggestionExceedsThreshold( + firstSuggestion, consideredWord, mAutoCorrectionThreshold)) { + // Score is too low for autocorrect + hasAutoCorrection = false; + } else { + // We have a high score, so we need to check if this suggestion is in the correct + // form to allow auto-correcting to it in this language. For details of how this + // is determined, see #isAllowedByAutoCorrectionWithSpaceFilter. + // TODO: this should not have its own logic here but be handled by the dictionary. + hasAutoCorrection = isAllowedByAutoCorrectionWithSpaceFilter(firstSuggestion); + } + } + + final SuggestedWordInfo typedWordInfo = new SuggestedWordInfo(typedWordString, + "" /* prevWordsContext */, SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + null == sourceDictionaryOfRemovedWord ? Dictionary.DICTIONARY_USER_TYPED + : sourceDictionaryOfRemovedWord, + SuggestedWordInfo.NOT_AN_INDEX /* indexOfTouchPointOfSecondWord */, + SuggestedWordInfo.NOT_A_CONFIDENCE /* autoCommitFirstWordConfidence */); + if (!TextUtils.isEmpty(typedWordString)) { + suggestionsContainer.add(0, typedWordInfo); + } + + final ArrayList<SuggestedWordInfo> suggestionsList; + if (DBG && !suggestionsContainer.isEmpty()) { + suggestionsList = getSuggestionsInfoListWithDebugInfo(typedWordString, + suggestionsContainer); + } else { + suggestionsList = suggestionsContainer; + } + + final int inputStyle; + if (resultsArePredictions) { + inputStyle = suggestionResults.mIsBeginningOfSentence + ? SuggestedWords.INPUT_STYLE_BEGINNING_OF_SENTENCE_PREDICTION + : SuggestedWords.INPUT_STYLE_PREDICTION; + } else { + inputStyle = inputStyleIfNotPrediction; + } + + final boolean isTypedWordValid = firstOcurrenceOfTypedWordInSuggestions > -1 + || (!resultsArePredictions && !allowsToBeAutoCorrected); + callback.onGetSuggestedWords(new SuggestedWords(suggestionsList, + suggestionResults.mRawSuggestions, typedWordInfo, + isTypedWordValid, + hasAutoCorrection /* willAutoCorrect */, + false /* isObsoleteSuggestions */, inputStyle, sequenceNumber)); + } + + // Retrieves suggestions for the batch input + // and calls the callback function with the suggestions. + private void getSuggestedWordsForBatchInput(final WordComposer wordComposer, + final NgramContext ngramContext, final Keyboard keyboard, + final SettingsValuesForSuggestion settingsValuesForSuggestion, + final int inputStyle, final int sequenceNumber, + final OnGetSuggestedWordsCallback callback) { + final SuggestionResults suggestionResults = mDictionaryFacilitator.getSuggestionResults( + wordComposer.getComposedDataSnapshot(), ngramContext, keyboard, + settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle); + // For transforming words that don't come from a dictionary, because it's our best bet + final Locale locale = mDictionaryFacilitator.getLocale(); + final ArrayList<SuggestedWordInfo> suggestionsContainer = + new ArrayList<>(suggestionResults); + final int suggestionsCount = suggestionsContainer.size(); + final boolean isFirstCharCapitalized = wordComposer.wasShiftedNoLock(); + final boolean isAllUpperCase = wordComposer.isAllUpperCase(); + if (isFirstCharCapitalized || isAllUpperCase) { + for (int i = 0; i < suggestionsCount; ++i) { + final SuggestedWordInfo wordInfo = suggestionsContainer.get(i); + final Locale wordlocale = wordInfo.mSourceDict.mLocale; + final SuggestedWordInfo transformedWordInfo = getTransformedSuggestedWordInfo( + wordInfo, null == wordlocale ? locale : wordlocale, isAllUpperCase, + isFirstCharCapitalized, 0 /* trailingSingleQuotesCount */); + suggestionsContainer.set(i, transformedWordInfo); + } + } + + if (SHOULD_REMOVE_PREVIOUSLY_REJECTED_SUGGESTION + && suggestionsContainer.size() > 1 + && TextUtils.equals(suggestionsContainer.get(0).mWord, + wordComposer.getRejectedBatchModeSuggestion())) { + final SuggestedWordInfo rejected = suggestionsContainer.remove(0); + suggestionsContainer.add(1, rejected); + } + SuggestedWordInfo.removeDups(null /* typedWord */, suggestionsContainer); + + // For some reason some suggestions with MIN_VALUE are making their way here. + // TODO: Find a more robust way to detect distracters. + for (int i = suggestionsContainer.size() - 1; i >= 0; --i) { + if (suggestionsContainer.get(i).mScore < SUPPRESS_SUGGEST_THRESHOLD) { + suggestionsContainer.remove(i); + } + } + + // In the batch input mode, the most relevant suggested word should act as a "typed word" + // (typedWordValid=true), not as an "auto correct word" (willAutoCorrect=false). + // Note that because this method is never used to get predictions, there is no need to + // modify inputType such in getSuggestedWordsForNonBatchInput. + final SuggestedWordInfo pseudoTypedWordInfo = suggestionsContainer.isEmpty() ? null + : suggestionsContainer.get(0); + + callback.onGetSuggestedWords(new SuggestedWords(suggestionsContainer, + suggestionResults.mRawSuggestions, + pseudoTypedWordInfo, + true /* typedWordValid */, + false /* willAutoCorrect */, + false /* isObsoleteSuggestions */, + inputStyle, sequenceNumber)); + } + + private static ArrayList<SuggestedWordInfo> getSuggestionsInfoListWithDebugInfo( + final String typedWord, final ArrayList<SuggestedWordInfo> suggestions) { + final SuggestedWordInfo typedWordInfo = suggestions.get(0); + typedWordInfo.setDebugString("+"); + final int suggestionsSize = suggestions.size(); + final ArrayList<SuggestedWordInfo> suggestionsList = new ArrayList<>(suggestionsSize); + suggestionsList.add(typedWordInfo); + // Note: i here is the index in mScores[], but the index in mSuggestions is one more + // than i because we added the typed word to mSuggestions without touching mScores. + for (int i = 0; i < suggestionsSize - 1; ++i) { + final SuggestedWordInfo cur = suggestions.get(i + 1); + final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore( + typedWord, cur.toString(), cur.mScore); + final String scoreInfoString; + if (normalizedScore > 0) { + scoreInfoString = String.format( + Locale.ROOT, "%d (%4.2f), %s", cur.mScore, normalizedScore, + cur.mSourceDict.mDictType); + } else { + scoreInfoString = Integer.toString(cur.mScore); + } + cur.setDebugString(scoreInfoString); + suggestionsList.add(cur); + } + return suggestionsList; + } + + /** + * Computes whether this suggestion should be blocked or not in this language + * + * This function implements a filter that avoids auto-correcting to suggestions that contain + * spaces that are above a certain language-dependent character limit. In languages like German + * where it's possible to concatenate many words, it often happens our dictionary does not + * have the longer words. In this case, we offer a lot of unhelpful suggestions that contain + * one or several spaces. Ideally we should understand what the user wants and display useful + * suggestions by improving the dictionary and possibly having some specific logic. Until + * that's possible we should avoid displaying unhelpful suggestions. But it's hard to tell + * whether a suggestion is useful or not. So at least for the time being we block + * auto-correction when the suggestion is long and contains a space, which should avoid the + * worst damage. + * This function is implementing that filter. If the language enforces no such limit, then it + * always returns true. If the suggestion contains no space, it also returns true. Otherwise, + * it checks the length against the language-specific limit. + * + * @param info the suggestion info + * @return whether it's fine to auto-correct to this. + */ + private static boolean isAllowedByAutoCorrectionWithSpaceFilter(final SuggestedWordInfo info) { + final Locale locale = info.mSourceDict.mLocale; + if (null == locale) { + return true; + } + final Integer maximumLengthForThisLanguage = + sLanguageToMaximumAutoCorrectionWithSpaceLength.get(locale.getLanguage()); + if (null == maximumLengthForThisLanguage) { + // This language does not enforce a maximum length to auto-correction + return true; + } + return info.mWord.length() <= maximumLengthForThisLanguage + || -1 == info.mWord.indexOf(Constants.CODE_SPACE); + } + + /* package for test */ static SuggestedWordInfo getTransformedSuggestedWordInfo( + final SuggestedWordInfo wordInfo, final Locale locale, final boolean isAllUpperCase, + final boolean isOnlyFirstCharCapitalized, final int trailingSingleQuotesCount) { + final StringBuilder sb = new StringBuilder(wordInfo.mWord.length()); + if (isAllUpperCase) { + sb.append(wordInfo.mWord.toUpperCase(locale)); + } else if (isOnlyFirstCharCapitalized) { + sb.append(StringUtils.capitalizeFirstCodePoint(wordInfo.mWord, locale)); + } else { + sb.append(wordInfo.mWord); + } + // Appending quotes is here to help people quote words. However, it's not helpful + // when they type words with quotes toward the end like "it's" or "didn't", where + // it's more likely the user missed the last character (or didn't type it yet). + final int quotesToAppend = trailingSingleQuotesCount + - (-1 == wordInfo.mWord.indexOf(Constants.CODE_SINGLE_QUOTE) ? 0 : 1); + for (int i = quotesToAppend - 1; i >= 0; --i) { + sb.appendCodePoint(Constants.CODE_SINGLE_QUOTE); + } + return new SuggestedWordInfo(sb.toString(), wordInfo.mPrevWordsContext, + wordInfo.mScore, wordInfo.mKindAndFlags, + wordInfo.mSourceDict, wordInfo.mIndexOfTouchPointOfSecondWord, + wordInfo.mAutoCommitFirstWordConfidence); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/SuggestedWords.java b/java/src/org/kelar/inputmethod/latin/SuggestedWords.java new file mode 100644 index 000000000..c704ef531 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/SuggestedWords.java @@ -0,0 +1,448 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.latin; + +import android.text.TextUtils; +import android.view.inputmethod.CompletionInfo; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class SuggestedWords { + public static final int INDEX_OF_TYPED_WORD = 0; + public static final int INDEX_OF_AUTO_CORRECTION = 1; + public static final int NOT_A_SEQUENCE_NUMBER = -1; + + public static final int INPUT_STYLE_NONE = 0; + public static final int INPUT_STYLE_TYPING = 1; + public static final int INPUT_STYLE_UPDATE_BATCH = 2; + public static final int INPUT_STYLE_TAIL_BATCH = 3; + public static final int INPUT_STYLE_APPLICATION_SPECIFIED = 4; + public static final int INPUT_STYLE_RECORRECTION = 5; + public static final int INPUT_STYLE_PREDICTION = 6; + public static final int INPUT_STYLE_BEGINNING_OF_SENTENCE_PREDICTION = 7; + + // The maximum number of suggestions available. + public static final int MAX_SUGGESTIONS = 18; + + private static final ArrayList<SuggestedWordInfo> EMPTY_WORD_INFO_LIST = new ArrayList<>(0); + @Nonnull + private static final SuggestedWords EMPTY = new SuggestedWords( + EMPTY_WORD_INFO_LIST, null /* rawSuggestions */, null /* typedWord */, + false /* typedWordValid */, false /* willAutoCorrect */, + false /* isObsoleteSuggestions */, INPUT_STYLE_NONE, NOT_A_SEQUENCE_NUMBER); + + @Nullable + public final SuggestedWordInfo mTypedWordInfo; + public final boolean mTypedWordValid; + // Note: this INCLUDES cases where the word will auto-correct to itself. A good definition + // of what this flag means would be "the top suggestion is strong enough to auto-correct", + // whether this exactly matches the user entry or not. + public final boolean mWillAutoCorrect; + public final boolean mIsObsoleteSuggestions; + // How the input for these suggested words was done by the user. Must be one of the + // INPUT_STYLE_* constants above. + public final int mInputStyle; + public final int mSequenceNumber; // Sequence number for auto-commit. + @Nonnull + protected final ArrayList<SuggestedWordInfo> mSuggestedWordInfoList; + @Nullable + public final ArrayList<SuggestedWordInfo> mRawSuggestions; + + public SuggestedWords(@Nonnull final ArrayList<SuggestedWordInfo> suggestedWordInfoList, + @Nullable final ArrayList<SuggestedWordInfo> rawSuggestions, + @Nullable final SuggestedWordInfo typedWordInfo, + final boolean typedWordValid, + final boolean willAutoCorrect, + final boolean isObsoleteSuggestions, + final int inputStyle, + final int sequenceNumber) { + mSuggestedWordInfoList = suggestedWordInfoList; + mRawSuggestions = rawSuggestions; + mTypedWordValid = typedWordValid; + mWillAutoCorrect = willAutoCorrect; + mIsObsoleteSuggestions = isObsoleteSuggestions; + mInputStyle = inputStyle; + mSequenceNumber = sequenceNumber; + mTypedWordInfo = typedWordInfo; + } + + public boolean isEmpty() { + return mSuggestedWordInfoList.isEmpty(); + } + + public int size() { + return mSuggestedWordInfoList.size(); + } + + /** + * Get suggested word to show as suggestions to UI. + * + * @param shouldShowLxxSuggestionUi true if showing suggestion UI introduced in LXX and later. + * @return the count of suggested word to show as suggestions to UI. + */ + public int getWordCountToShow(final boolean shouldShowLxxSuggestionUi) { + if (isPrediction() || !shouldShowLxxSuggestionUi) { + return size(); + } + return size() - /* typed word */ 1; + } + + /** + * Get {@link SuggestedWordInfo} object for the typed word. + * @return The {@link SuggestedWordInfo} object for the typed word. + */ + public SuggestedWordInfo getTypedWordInfo() { + return mTypedWordInfo; + } + + /** + * Get suggested word at <code>index</code>. + * @param index The index of the suggested word. + * @return The suggested word. + */ + public String getWord(final int index) { + return mSuggestedWordInfoList.get(index).mWord; + } + + /** + * Get displayed text at <code>index</code>. + * In RTL languages, the displayed text on the suggestion strip may be different from the + * suggested word that is returned from {@link #getWord(int)}. For example the displayed text + * of punctuation suggestion "(" should be ")". + * @param index The index of the text to display. + * @return The text to be displayed. + */ + public String getLabel(final int index) { + return mSuggestedWordInfoList.get(index).mWord; + } + + /** + * Get {@link SuggestedWordInfo} object at <code>index</code>. + * @param index The index of the {@link SuggestedWordInfo}. + * @return The {@link SuggestedWordInfo} object. + */ + public SuggestedWordInfo getInfo(final int index) { + return mSuggestedWordInfoList.get(index); + } + + /** + * Gets the suggestion index from the suggestions list. + * @param suggestedWordInfo The {@link SuggestedWordInfo} to find the index. + * @return The position of the suggestion in the suggestion list. + */ + public int indexOf(SuggestedWordInfo suggestedWordInfo) { + return mSuggestedWordInfoList.indexOf(suggestedWordInfo); + } + + public String getDebugString(final int pos) { + if (!DebugFlags.DEBUG_ENABLED) { + return null; + } + final SuggestedWordInfo wordInfo = getInfo(pos); + if (wordInfo == null) { + return null; + } + final String debugString = wordInfo.getDebugString(); + if (TextUtils.isEmpty(debugString)) { + return null; + } + return debugString; + } + + /** + * The predicator to tell whether this object represents punctuation suggestions. + * @return false if this object desn't represent punctuation suggestions. + */ + public boolean isPunctuationSuggestions() { + return false; + } + + @Override + public String toString() { + // Pretty-print method to help debug + return "SuggestedWords:" + + " mTypedWordValid=" + mTypedWordValid + + " mWillAutoCorrect=" + mWillAutoCorrect + + " mInputStyle=" + mInputStyle + + " words=" + Arrays.toString(mSuggestedWordInfoList.toArray()); + } + + public static ArrayList<SuggestedWordInfo> getFromApplicationSpecifiedCompletions( + final CompletionInfo[] infos) { + final ArrayList<SuggestedWordInfo> result = new ArrayList<>(); + for (final CompletionInfo info : infos) { + if (null == info || null == info.getText()) { + continue; + } + result.add(new SuggestedWordInfo(info)); + } + return result; + } + + @Nonnull + public static final SuggestedWords getEmptyInstance() { + return SuggestedWords.EMPTY; + } + + // Should get rid of the first one (what the user typed previously) from suggestions + // and replace it with what the user currently typed. + public static ArrayList<SuggestedWordInfo> getTypedWordAndPreviousSuggestions( + @Nonnull final SuggestedWordInfo typedWordInfo, + @Nonnull final SuggestedWords previousSuggestions) { + final ArrayList<SuggestedWordInfo> suggestionsList = new ArrayList<>(); + final HashSet<String> alreadySeen = new HashSet<>(); + suggestionsList.add(typedWordInfo); + alreadySeen.add(typedWordInfo.mWord); + final int previousSize = previousSuggestions.size(); + for (int index = 1; index < previousSize; index++) { + final SuggestedWordInfo prevWordInfo = previousSuggestions.getInfo(index); + final String prevWord = prevWordInfo.mWord; + // Filter out duplicate suggestions. + if (!alreadySeen.contains(prevWord)) { + suggestionsList.add(prevWordInfo); + alreadySeen.add(prevWord); + } + } + return suggestionsList; + } + + public SuggestedWordInfo getAutoCommitCandidate() { + if (mSuggestedWordInfoList.size() <= 0) return null; + final SuggestedWordInfo candidate = mSuggestedWordInfoList.get(0); + return candidate.isEligibleForAutoCommit() ? candidate : null; + } + + // non-final for testability. + public static class SuggestedWordInfo { + public static final int NOT_AN_INDEX = -1; + public static final int NOT_A_CONFIDENCE = -1; + public static final int MAX_SCORE = Integer.MAX_VALUE; + + private static final int KIND_MASK_KIND = 0xFF; // Mask to get only the kind + public static final int KIND_TYPED = 0; // What user typed + public static final int KIND_CORRECTION = 1; // Simple correction/suggestion + public static final int KIND_COMPLETION = 2; // Completion (suggestion with appended chars) + public static final int KIND_WHITELIST = 3; // Whitelisted word + public static final int KIND_BLACKLIST = 4; // Blacklisted word + public static final int KIND_HARDCODED = 5; // Hardcoded suggestion, e.g. punctuation + public static final int KIND_APP_DEFINED = 6; // Suggested by the application + public static final int KIND_SHORTCUT = 7; // A shortcut + public static final int KIND_PREDICTION = 8; // A prediction (== a suggestion with no input) + // KIND_RESUMED: A resumed suggestion (comes from a span, currently this type is used only + // in java for re-correction) + public static final int KIND_RESUMED = 9; + public static final int KIND_OOV_CORRECTION = 10; // Most probable string correction + + public static final int KIND_FLAG_POSSIBLY_OFFENSIVE = 0x80000000; + public static final int KIND_FLAG_EXACT_MATCH = 0x40000000; + public static final int KIND_FLAG_EXACT_MATCH_WITH_INTENTIONAL_OMISSION = 0x20000000; + public static final int KIND_FLAG_APPROPRIATE_FOR_AUTO_CORRECTION = 0x10000000; + + public final String mWord; + public final String mPrevWordsContext; + // The completion info from the application. Null for suggestions that don't come from + // the application (including keyboard-computed ones, so this is almost always null) + public final CompletionInfo mApplicationSpecifiedCompletionInfo; + public final int mScore; + public final int mKindAndFlags; + public final int mCodePointCount; + @Deprecated + public final Dictionary mSourceDict; + // For auto-commit. This keeps track of the index inside the touch coordinates array + // passed to native code to get suggestions for a gesture that corresponds to the first + // letter of the second word. + public final int mIndexOfTouchPointOfSecondWord; + // For auto-commit. This is a measure of how confident we are that we can commit the + // first word of this suggestion. + public final int mAutoCommitFirstWordConfidence; + private String mDebugString = ""; + + /** + * Create a new suggested word info. + * @param word The string to suggest. + * @param prevWordsContext previous words context. + * @param score A measure of how likely this suggestion is. + * @param kindAndFlags The kind of suggestion, as one of the above KIND_* constants with + * flags. + * @param sourceDict What instance of Dictionary produced this suggestion. + * @param indexOfTouchPointOfSecondWord See mIndexOfTouchPointOfSecondWord. + * @param autoCommitFirstWordConfidence See mAutoCommitFirstWordConfidence. + */ + public SuggestedWordInfo(final String word, final String prevWordsContext, + final int score, final int kindAndFlags, + final Dictionary sourceDict, final int indexOfTouchPointOfSecondWord, + final int autoCommitFirstWordConfidence) { + mWord = word; + mPrevWordsContext = prevWordsContext; + mApplicationSpecifiedCompletionInfo = null; + mScore = score; + mKindAndFlags = kindAndFlags; + mSourceDict = sourceDict; + mCodePointCount = StringUtils.codePointCount(mWord); + mIndexOfTouchPointOfSecondWord = indexOfTouchPointOfSecondWord; + mAutoCommitFirstWordConfidence = autoCommitFirstWordConfidence; + } + + /** + * Create a new suggested word info from an application-specified completion. + * If the passed argument or its contained text is null, this throws a NPE. + * @param applicationSpecifiedCompletion The application-specified completion info. + */ + public SuggestedWordInfo(final CompletionInfo applicationSpecifiedCompletion) { + mWord = applicationSpecifiedCompletion.getText().toString(); + mPrevWordsContext = ""; + mApplicationSpecifiedCompletionInfo = applicationSpecifiedCompletion; + mScore = SuggestedWordInfo.MAX_SCORE; + mKindAndFlags = SuggestedWordInfo.KIND_APP_DEFINED; + mSourceDict = Dictionary.DICTIONARY_APPLICATION_DEFINED; + mCodePointCount = StringUtils.codePointCount(mWord); + mIndexOfTouchPointOfSecondWord = SuggestedWordInfo.NOT_AN_INDEX; + mAutoCommitFirstWordConfidence = SuggestedWordInfo.NOT_A_CONFIDENCE; + } + + public boolean isEligibleForAutoCommit() { + return (isKindOf(KIND_CORRECTION) && NOT_AN_INDEX != mIndexOfTouchPointOfSecondWord); + } + + public int getKind() { + return (mKindAndFlags & KIND_MASK_KIND); + } + + public boolean isKindOf(final int kind) { + return getKind() == kind; + } + + public boolean isPossiblyOffensive() { + return (mKindAndFlags & KIND_FLAG_POSSIBLY_OFFENSIVE) != 0; + } + + public boolean isExactMatch() { + return (mKindAndFlags & KIND_FLAG_EXACT_MATCH) != 0; + } + + public boolean isExactMatchWithIntentionalOmission() { + return (mKindAndFlags & KIND_FLAG_EXACT_MATCH_WITH_INTENTIONAL_OMISSION) != 0; + } + + public boolean isAprapreateForAutoCorrection() { + return (mKindAndFlags & KIND_FLAG_APPROPRIATE_FOR_AUTO_CORRECTION) != 0; + } + + public void setDebugString(final String str) { + if (null == str) throw new NullPointerException("Debug info is null"); + mDebugString = str; + } + + public String getDebugString() { + return mDebugString; + } + + public String getWord() { + return mWord; + } + + @Deprecated + public Dictionary getSourceDictionary() { + return mSourceDict; + } + + public int codePointAt(int i) { + return mWord.codePointAt(i); + } + + @Override + public String toString() { + if (TextUtils.isEmpty(mDebugString)) { + return mWord; + } + return mWord + " (" + mDebugString + ")"; + } + + /** + * This will always remove the higher index if a duplicate is found. + * + * @return position of typed word in the candidate list + */ + public static int removeDups( + @Nullable final String typedWord, + @Nonnull final ArrayList<SuggestedWordInfo> candidates) { + if (candidates.isEmpty()) { + return -1; + } + int firstOccurrenceOfWord = -1; + if (!TextUtils.isEmpty(typedWord)) { + firstOccurrenceOfWord = removeSuggestedWordInfoFromList( + typedWord, candidates, -1 /* startIndexExclusive */); + } + for (int i = 0; i < candidates.size(); ++i) { + removeSuggestedWordInfoFromList( + candidates.get(i).mWord, candidates, i /* startIndexExclusive */); + } + return firstOccurrenceOfWord; + } + + private static int removeSuggestedWordInfoFromList( + @Nonnull final String word, + @Nonnull final ArrayList<SuggestedWordInfo> candidates, + final int startIndexExclusive) { + int firstOccurrenceOfWord = -1; + for (int i = startIndexExclusive + 1; i < candidates.size(); ++i) { + final SuggestedWordInfo previous = candidates.get(i); + if (word.equals(previous.mWord)) { + if (firstOccurrenceOfWord == -1) { + firstOccurrenceOfWord = i; + } + candidates.remove(i); + --i; + } + } + return firstOccurrenceOfWord; + } + } + + private static boolean isPrediction(final int inputStyle) { + return INPUT_STYLE_PREDICTION == inputStyle + || INPUT_STYLE_BEGINNING_OF_SENTENCE_PREDICTION == inputStyle; + } + + public boolean isPrediction() { + return isPrediction(mInputStyle); + } + + /** + * @return the {@link SuggestedWordInfo} which corresponds to the word that is originally + * typed by the user. Otherwise returns {@code null}. Note that gesture input is not + * considered to be a typed word. + */ + @UsedForTesting + public SuggestedWordInfo getTypedWordInfoOrNull() { + if (SuggestedWords.INDEX_OF_TYPED_WORD >= size()) { + return null; + } + final SuggestedWordInfo info = getInfo(SuggestedWords.INDEX_OF_TYPED_WORD); + return (info.getKind() == SuggestedWordInfo.KIND_TYPED) ? info : null; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/SystemBroadcastReceiver.java b/java/src/org/kelar/inputmethod/latin/SystemBroadcastReceiver.java new file mode 100644 index 000000000..78c016353 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/SystemBroadcastReceiver.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin; + +import android.app.DownloadManager; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.os.Process; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.inputmethod.InputMethodManager; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.dictionarypack.DictionaryPackConstants; +import org.kelar.inputmethod.dictionarypack.DownloadManagerWrapper; +import org.kelar.inputmethod.keyboard.KeyboardLayoutSet; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.setup.SetupActivity; +import org.kelar.inputmethod.latin.utils.UncachedInputMethodManagerUtils; + +/** + * This class detects the {@link Intent#ACTION_MY_PACKAGE_REPLACED} broadcast intent when this IME + * package has been replaced by a newer version of the same package. This class also detects + * {@link Intent#ACTION_BOOT_COMPLETED} and {@link Intent#ACTION_USER_INITIALIZE} broadcast intent. + * + * If this IME has already been installed in the system image and a new version of this IME has + * been installed, {@link Intent#ACTION_MY_PACKAGE_REPLACED} is received by this receiver and it + * will hide the setup wizard's icon. + * + * If this IME has already been installed in the data partition and a new version of this IME has + * been installed, {@link Intent#ACTION_MY_PACKAGE_REPLACED} is received by this receiver but it + * will not hide the setup wizard's icon, and the icon will appear on the launcher. + * + * If this IME hasn't been installed yet and has been newly installed, no + * {@link Intent#ACTION_MY_PACKAGE_REPLACED} will be sent and the setup wizard's icon will appear + * on the launcher. + * + * When the device has been booted, {@link Intent#ACTION_BOOT_COMPLETED} is received by this + * receiver and it checks whether the setup wizard's icon should be appeared or not on the launcher + * depending on which partition this IME is installed. + * + * When the system locale has been changed, {@link Intent#ACTION_LOCALE_CHANGED} is received by + * this receiver and the {@link KeyboardLayoutSet}'s cache is cleared. + */ +public final class SystemBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = SystemBroadcastReceiver.class.getSimpleName(); + + @Override + public void onReceive(final Context context, final Intent intent) { + final String intentAction = intent.getAction(); + if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(intentAction)) { + Log.i(TAG, "Package has been replaced: " + context.getPackageName()); + // Need to restore additional subtypes because system always clears additional + // subtypes when the package is replaced. + RichInputMethodManager.init(context); + final RichInputMethodManager richImm = RichInputMethodManager.getInstance(); + final InputMethodSubtype[] additionalSubtypes = richImm.getAdditionalSubtypes(); + richImm.setAdditionalInputMethodSubtypes(additionalSubtypes); + toggleAppIcon(context); + + // Remove all the previously scheduled downloads. This will also makes sure + // that any erroneously stuck downloads will get cleared. (b/21797386) + removeOldDownloads(context); + // b/21797386 + // downloadLatestDictionaries(context); + } else if (Intent.ACTION_BOOT_COMPLETED.equals(intentAction)) { + Log.i(TAG, "Boot has been completed"); + toggleAppIcon(context); + } else if (Intent.ACTION_LOCALE_CHANGED.equals(intentAction)) { + Log.i(TAG, "System locale changed"); + KeyboardLayoutSet.onSystemLocaleChanged(); + } + + // The process that hosts this broadcast receiver is invoked and remains alive even after + // 1) the package has been re-installed, + // 2) the device has just booted, + // 3) a new user has been created. + // There is no good reason to keep the process alive if this IME isn't a current IME. + final InputMethodManager imm = (InputMethodManager) + context.getSystemService(Context.INPUT_METHOD_SERVICE); + // Called to check whether this IME has been triggered by the current user or not + final boolean isInputMethodManagerValidForUserOfThisProcess = + !imm.getInputMethodList().isEmpty(); + final boolean isCurrentImeOfCurrentUser = isInputMethodManagerValidForUserOfThisProcess + && UncachedInputMethodManagerUtils.isThisImeCurrent(context, imm); + if (!isCurrentImeOfCurrentUser) { + final int myPid = Process.myPid(); + Log.i(TAG, "Killing my process: pid=" + myPid); + Process.killProcess(myPid); + } + } + + private void removeOldDownloads(Context context) { + try { + Log.i(TAG, "Removing the old downloads in progress of the previous keyboard version."); + final DownloadManagerWrapper downloadManagerWrapper = new DownloadManagerWrapper( + context); + final DownloadManager.Query q = new DownloadManager.Query(); + // Query all the download statuses except the succeeded ones. + q.setFilterByStatus(DownloadManager.STATUS_FAILED + | DownloadManager.STATUS_PAUSED + | DownloadManager.STATUS_PENDING + | DownloadManager.STATUS_RUNNING); + final Cursor c = downloadManagerWrapper.query(q); + if (c != null) { + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + final long downloadId = c + .getLong(c.getColumnIndex(DownloadManager.COLUMN_ID)); + downloadManagerWrapper.remove(downloadId); + Log.i(TAG, "Removed the download with Id: " + downloadId); + } + c.close(); + } + } catch (Exception e) { + Log.e(TAG, "Exception while removing old downloads."); + } + } + + private void downloadLatestDictionaries(Context context) { + final Intent updateIntent = new Intent( + DictionaryPackConstants.INIT_AND_UPDATE_NOW_INTENT_ACTION); + context.sendBroadcast(updateIntent); + } + + public static void toggleAppIcon(final Context context) { + final int appInfoFlags = context.getApplicationInfo().flags; + final boolean isSystemApp = (appInfoFlags & ApplicationInfo.FLAG_SYSTEM) > 0; + if (Log.isLoggable(TAG, Log.INFO)) { + Log.i(TAG, "toggleAppIcon() : FLAG_SYSTEM = " + isSystemApp); + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + context.getPackageManager().setComponentEnabledSetting( + new ComponentName(context, SetupActivity.class), + Settings.readShowSetupWizardIcon(prefs, context) + ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED + : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/UserBinaryDictionary.java b/java/src/org/kelar/inputmethod/latin/UserBinaryDictionary.java new file mode 100644 index 000000000..57a10b0a7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/UserBinaryDictionary.java @@ -0,0 +1,216 @@ +/* + * 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 org.kelar.inputmethod.latin; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.provider.UserDictionary.Words; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.io.File; +import java.util.Arrays; +import java.util.Locale; + +import javax.annotation.Nullable; + +/** + * An expandable dictionary that stores the words in the user dictionary provider into a binary + * dictionary file to use it from native code. + */ +public class UserBinaryDictionary extends ExpandableBinaryDictionary { + private static final String TAG = ExpandableBinaryDictionary.class.getSimpleName(); + + // The user dictionary provider uses an empty string to mean "all languages". + private static final String USER_DICTIONARY_ALL_LANGUAGES = ""; + private static final int HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY = 250; + private static final int LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY = 160; + + private static final String[] PROJECTION_QUERY = new String[] {Words.WORD, Words.FREQUENCY}; + + private static final String NAME = "userunigram"; + + private ContentObserver mObserver; + final private String mLocaleString; + final private boolean mAlsoUseMoreRestrictiveLocales; + + protected UserBinaryDictionary(final Context context, final Locale locale, + final boolean alsoUseMoreRestrictiveLocales, + final File dictFile, final String name) { + super(context, getDictName(name, locale, dictFile), locale, Dictionary.TYPE_USER, dictFile); + if (null == locale) throw new NullPointerException(); // Catch the error earlier + final String localeStr = locale.toString(); + if (SubtypeLocaleUtils.NO_LANGUAGE.equals(localeStr)) { + // If we don't have a locale, insert into the "all locales" user dictionary. + mLocaleString = USER_DICTIONARY_ALL_LANGUAGES; + } else { + mLocaleString = localeStr; + } + mAlsoUseMoreRestrictiveLocales = alsoUseMoreRestrictiveLocales; + ContentResolver cres = context.getContentResolver(); + + mObserver = new ContentObserver(null) { + @Override + public void onChange(final boolean self) { + // This hook is deprecated as of API level 16 (Build.VERSION_CODES.JELLY_BEAN), + // but should still be supported for cases where the IME is running on an older + // version of the platform. + onChange(self, null); + } + // The following hook is only available as of API level 16 + // (Build.VERSION_CODES.JELLY_BEAN), and as such it will only work on JellyBean+ + // devices. On older versions of the platform, the hook above will be called instead. + @Override + public void onChange(final boolean self, final Uri uri) { + setNeedsToRecreate(); + } + }; + cres.registerContentObserver(Words.CONTENT_URI, true, mObserver); + reloadDictionaryIfRequired(); + } + + // Note: This method is called by {@link DictionaryFacilitator} using Java reflection. + @ExternallyReferenced + public static UserBinaryDictionary getDictionary( + final Context context, final Locale locale, final File dictFile, + final String dictNamePrefix, @Nullable final String account) { + return new UserBinaryDictionary( + context, locale, false /* alsoUseMoreRestrictiveLocales */, + dictFile, dictNamePrefix + NAME); + } + + @Override + public synchronized void close() { + if (mObserver != null) { + mContext.getContentResolver().unregisterContentObserver(mObserver); + mObserver = null; + } + super.close(); + } + + @Override + public void loadInitialContentsLocked() { + // Split the locale. For example "en" => ["en"], "de_DE" => ["de", "DE"], + // "en_US_foo_bar_qux" => ["en", "US", "foo_bar_qux"] because of the limit of 3. + // This is correct for locale processing. + // For this example, we'll look at the "en_US_POSIX" case. + final String[] localeElements = + TextUtils.isEmpty(mLocaleString) ? new String[] {} : mLocaleString.split("_", 3); + final int length = localeElements.length; + + final StringBuilder request = new StringBuilder("(locale is NULL)"); + String localeSoFar = ""; + // At start, localeElements = ["en", "US", "POSIX"] ; localeSoFar = "" ; + // and request = "(locale is NULL)" + for (int i = 0; i < length; ++i) { + // i | localeSoFar | localeElements + // 0 | "" | ["en", "US", "POSIX"] + // 1 | "en_" | ["en", "US", "POSIX"] + // 2 | "en_US_" | ["en", "en_US", "POSIX"] + localeElements[i] = localeSoFar + localeElements[i]; + localeSoFar = localeElements[i] + "_"; + // i | request + // 0 | "(locale is NULL)" + // 1 | "(locale is NULL) or (locale=?)" + // 2 | "(locale is NULL) or (locale=?) or (locale=?)" + request.append(" or (locale=?)"); + } + // At the end, localeElements = ["en", "en_US", "en_US_POSIX"]; localeSoFar = en_US_POSIX_" + // and request = "(locale is NULL) or (locale=?) or (locale=?) or (locale=?)" + + final String[] requestArguments; + // If length == 3, we already have all the arguments we need (common prefix is meaningless + // inside variants + if (mAlsoUseMoreRestrictiveLocales && length < 3) { + request.append(" or (locale like ?)"); + // The following creates an array with one more (null) position + final String[] localeElementsWithMoreRestrictiveLocalesIncluded = + Arrays.copyOf(localeElements, length + 1); + localeElementsWithMoreRestrictiveLocalesIncluded[length] = + localeElements[length - 1] + "_%"; + requestArguments = localeElementsWithMoreRestrictiveLocalesIncluded; + // If for example localeElements = ["en"] + // then requestArguments = ["en", "en_%"] + // and request = (locale is NULL) or (locale=?) or (locale like ?) + // If localeElements = ["en", "en_US"] + // then requestArguments = ["en", "en_US", "en_US_%"] + } else { + requestArguments = localeElements; + } + final String requestString = request.toString(); + addWordsFromProjectionLocked(PROJECTION_QUERY, requestString, requestArguments); + } + + private void addWordsFromProjectionLocked(final String[] query, String request, + final String[] requestArguments) + throws IllegalArgumentException { + Cursor cursor = null; + try { + cursor = mContext.getContentResolver().query( + Words.CONTENT_URI, query, request, requestArguments, null); + addWordsLocked(cursor); + } catch (final SQLiteException e) { + Log.e(TAG, "SQLiteException in the remote User dictionary process.", e); + } finally { + try { + if (null != cursor) cursor.close(); + } catch (final SQLiteException e) { + Log.e(TAG, "SQLiteException in the remote User dictionary process.", e); + } + } + } + + private static int scaleFrequencyFromDefaultToLatinIme(final int defaultFrequency) { + // The default frequency for the user dictionary is 250 for historical reasons. + // Latin IME considers a good value for the default user dictionary frequency + // is about 160 considering the scale we use. So we are scaling down the values. + if (defaultFrequency > Integer.MAX_VALUE / LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY) { + return (defaultFrequency / HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY) + * LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY; + } + return (defaultFrequency * LATINIME_DEFAULT_USER_DICTIONARY_FREQUENCY) + / HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY; + } + + private void addWordsLocked(final Cursor cursor) { + if (cursor == null) return; + if (cursor.moveToFirst()) { + final int indexWord = cursor.getColumnIndex(Words.WORD); + final int indexFrequency = cursor.getColumnIndex(Words.FREQUENCY); + while (!cursor.isAfterLast()) { + final String word = cursor.getString(indexWord); + final int frequency = cursor.getInt(indexFrequency); + final int adjustedFrequency = scaleFrequencyFromDefaultToLatinIme(frequency); + // Safeguard against adding really long words. + if (word.length() <= MAX_WORD_LENGTH) { + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addUnigramLocked(word, adjustedFrequency, false /* isNotAWord */, + false /* isPossiblyOffensive */, + BinaryDictionary.NOT_A_VALID_TIMESTAMP); + } + cursor.moveToNext(); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/WordComposer.java b/java/src/org/kelar/inputmethod/latin/WordComposer.java new file mode 100644 index 000000000..5f05aeab7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/WordComposer.java @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.event.CombinerChain; +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.CoordinateUtils; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; + +import java.util.ArrayList; +import java.util.Collections; + +import javax.annotation.Nonnull; + +/** + * A place to store the currently composing word with information such as adjacent key codes as well + */ +public final class WordComposer { + private static final int MAX_WORD_LENGTH = DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH; + private static final boolean DBG = DebugFlags.DEBUG_ENABLED; + + public static final int CAPS_MODE_OFF = 0; + // 1 is shift bit, 2 is caps bit, 4 is auto bit but this is just a convention as these bits + // aren't used anywhere in the code + public static final int CAPS_MODE_MANUAL_SHIFTED = 0x1; + public static final int CAPS_MODE_MANUAL_SHIFT_LOCKED = 0x3; + public static final int CAPS_MODE_AUTO_SHIFTED = 0x5; + public static final int CAPS_MODE_AUTO_SHIFT_LOCKED = 0x7; + + private CombinerChain mCombinerChain; + private String mCombiningSpec; // Memory so that we don't uselessly recreate the combiner chain + + // The list of events that served to compose this string. + private final ArrayList<Event> mEvents; + private final InputPointers mInputPointers = new InputPointers(MAX_WORD_LENGTH); + private SuggestedWordInfo mAutoCorrection; + private boolean mIsResumed; + private boolean mIsBatchMode; + // A memory of the last rejected batch mode suggestion, if any. This goes like this: the user + // gestures a word, is displeased with the results and hits backspace, then gestures again. + // At the very least we should avoid re-suggesting the same thing, and to do that we memorize + // the rejected suggestion in this variable. + // TODO: this should be done in a comprehensive way by the User History feature instead of + // as an ad-hockery here. + private String mRejectedBatchModeSuggestion; + + // Cache these values for performance + private CharSequence mTypedWordCache; + private int mCapsCount; + private int mDigitsCount; + private int mCapitalizedMode; + // This is the number of code points entered so far. This is not limited to MAX_WORD_LENGTH. + // In general, this contains the size of mPrimaryKeyCodes, except when this is greater than + // MAX_WORD_LENGTH in which case mPrimaryKeyCodes only contain the first MAX_WORD_LENGTH + // code points. + private int mCodePointSize; + private int mCursorPositionWithinWord; + + /** + * Whether the composing word has the only first char capitalized. + */ + private boolean mIsOnlyFirstCharCapitalized; + + public WordComposer() { + mCombinerChain = new CombinerChain(""); + mEvents = new ArrayList<>(); + mAutoCorrection = null; + mIsResumed = false; + mIsBatchMode = false; + mCursorPositionWithinWord = 0; + mRejectedBatchModeSuggestion = null; + refreshTypedWordCache(); + } + + public ComposedData getComposedDataSnapshot() { + return new ComposedData(getInputPointers(), isBatchMode(), mTypedWordCache.toString()); + } + + /** + * Restart the combiners, possibly with a new spec. + * @param combiningSpec The spec string for combining. This is found in the extra value. + */ + public void restartCombining(final String combiningSpec) { + final String nonNullCombiningSpec = null == combiningSpec ? "" : combiningSpec; + if (!nonNullCombiningSpec.equals(mCombiningSpec)) { + mCombinerChain = new CombinerChain( + mCombinerChain.getComposingWordWithCombiningFeedback().toString()); + mCombiningSpec = nonNullCombiningSpec; + } + } + + /** + * Clear out the keys registered so far. + */ + public void reset() { + mCombinerChain.reset(); + mEvents.clear(); + mAutoCorrection = null; + mCapsCount = 0; + mDigitsCount = 0; + mIsOnlyFirstCharCapitalized = false; + mIsResumed = false; + mIsBatchMode = false; + mCursorPositionWithinWord = 0; + mRejectedBatchModeSuggestion = null; + refreshTypedWordCache(); + } + + private final void refreshTypedWordCache() { + mTypedWordCache = mCombinerChain.getComposingWordWithCombiningFeedback(); + mCodePointSize = Character.codePointCount(mTypedWordCache, 0, mTypedWordCache.length()); + } + + /** + * Number of keystrokes in the composing word. + * @return the number of keystrokes + */ + public int size() { + return mCodePointSize; + } + + public boolean isSingleLetter() { + return size() == 1; + } + + public final boolean isComposingWord() { + return size() > 0; + } + + public InputPointers getInputPointers() { + return mInputPointers; + } + + /** + * Process an event and return an event, and return a processed event to apply. + * @param event the unprocessed event. + * @return the processed event. Never null, but may be marked as consumed. + */ + @Nonnull + public Event processEvent(@Nonnull final Event event) { + final Event processedEvent = mCombinerChain.processEvent(mEvents, event); + // The retained state of the combiner chain may have changed while processing the event, + // so we need to update our cache. + refreshTypedWordCache(); + mEvents.add(event); + return processedEvent; + } + + /** + * Apply a processed input event. + * + * All input events should be supported, including software/hardware events, characters as well + * as deletions, multiple inputs and gestures. + * + * @param event the event to apply. Must not be null. + */ + public void applyProcessedEvent(final Event event) { + mCombinerChain.applyProcessedEvent(event); + final int primaryCode = event.mCodePoint; + final int keyX = event.mX; + final int keyY = event.mY; + final int newIndex = size(); + refreshTypedWordCache(); + mCursorPositionWithinWord = mCodePointSize; + // We may have deleted the last one. + if (0 == mCodePointSize) { + mIsOnlyFirstCharCapitalized = false; + } + if (Constants.CODE_DELETE != event.mKeyCode) { + if (newIndex < MAX_WORD_LENGTH) { + // In the batch input mode, the {@code mInputPointers} holds batch input points and + // shouldn't be overridden by the "typed key" coordinates + // (See {@link #setBatchInputWord}). + if (!mIsBatchMode) { + // TODO: Set correct pointer id and time + mInputPointers.addPointerAt(newIndex, keyX, keyY, 0, 0); + } + } + if (0 == newIndex) { + mIsOnlyFirstCharCapitalized = Character.isUpperCase(primaryCode); + } else { + mIsOnlyFirstCharCapitalized = mIsOnlyFirstCharCapitalized + && !Character.isUpperCase(primaryCode); + } + if (Character.isUpperCase(primaryCode)) mCapsCount++; + if (Character.isDigit(primaryCode)) mDigitsCount++; + } + mAutoCorrection = null; + } + + public void setCursorPositionWithinWord(final int posWithinWord) { + mCursorPositionWithinWord = posWithinWord; + // TODO: compute where that puts us inside the events + } + + public boolean isCursorFrontOrMiddleOfComposingWord() { + if (DBG && mCursorPositionWithinWord > mCodePointSize) { + throw new RuntimeException("Wrong cursor position : " + mCursorPositionWithinWord + + "in a word of size " + mCodePointSize); + } + return mCursorPositionWithinWord != mCodePointSize; + } + + /** + * When the cursor is moved by the user, we need to update its position. + * If it falls inside the currently composing word, we don't reset the composition, and + * only update the cursor position. + * + * @param expectedMoveAmount How many java chars to move the cursor. Negative values move + * the cursor backward, positive values move the cursor forward. + * @return true if the cursor is still inside the composing word, false otherwise. + */ + public boolean moveCursorByAndReturnIfInsideComposingWord(final int expectedMoveAmount) { + int actualMoveAmount = 0; + int cursorPos = mCursorPositionWithinWord; + // TODO: Don't make that copy. We can do this directly from mTypedWordCache. + final int[] codePoints = StringUtils.toCodePointArray(mTypedWordCache); + if (expectedMoveAmount >= 0) { + // Moving the cursor forward for the expected amount or until the end of the word has + // been reached, whichever comes first. + while (actualMoveAmount < expectedMoveAmount && cursorPos < codePoints.length) { + actualMoveAmount += Character.charCount(codePoints[cursorPos]); + ++cursorPos; + } + } else { + // Moving the cursor backward for the expected amount or until the start of the word + // has been reached, whichever comes first. + while (actualMoveAmount > expectedMoveAmount && cursorPos > 0) { + --cursorPos; + actualMoveAmount -= Character.charCount(codePoints[cursorPos]); + } + } + // If the actual and expected amounts differ, we crossed the start or the end of the word + // so the result would not be inside the composing word. + if (actualMoveAmount != expectedMoveAmount) { + return false; + } + mCursorPositionWithinWord = cursorPos; + mCombinerChain.applyProcessedEvent(mCombinerChain.processEvent( + mEvents, Event.createCursorMovedEvent(cursorPos))); + return true; + } + + public void setBatchInputPointers(final InputPointers batchPointers) { + mInputPointers.set(batchPointers); + mIsBatchMode = true; + } + + public void setBatchInputWord(final String word) { + reset(); + mIsBatchMode = true; + final int length = word.length(); + for (int i = 0; i < length; i = Character.offsetByCodePoints(word, i, 1)) { + final int codePoint = Character.codePointAt(word, i); + // We don't want to override the batch input points that are held in mInputPointers + // (See {@link #add(int,int,int)}). + final Event processedEvent = + processEvent(Event.createEventForCodePointFromUnknownSource(codePoint)); + applyProcessedEvent(processedEvent); + } + } + + /** + * Set the currently composing word to the one passed as an argument. + * This will register NOT_A_COORDINATE for X and Ys, and use the passed keyboard for proximity. + * @param codePoints the code points to set as the composing word. + * @param coordinates the x, y coordinates of the key in the CoordinateUtils format + */ + public void setComposingWord(final int[] codePoints, final int[] coordinates) { + reset(); + final int length = codePoints.length; + for (int i = 0; i < length; ++i) { + final Event processedEvent = + processEvent(Event.createEventForCodePointFromAlreadyTypedText(codePoints[i], + CoordinateUtils.xFromArray(coordinates, i), + CoordinateUtils.yFromArray(coordinates, i))); + applyProcessedEvent(processedEvent); + } + mIsResumed = true; + } + + /** + * Returns the word as it was typed, without any correction applied. + * @return the word that was typed so far. Never returns null. + */ + public String getTypedWord() { + return mTypedWordCache.toString(); + } + + /** + * Whether this composer is composing or about to compose a word in which only the first letter + * is a capital. + * + * If we do have a composing word, we just return whether the word has indeed only its first + * character capitalized. If we don't, then we return a value based on the capitalized mode, + * which tell us what is likely to happen for the next composing word. + * + * @return capitalization preference + */ + public boolean isOrWillBeOnlyFirstCharCapitalized() { + return isComposingWord() ? mIsOnlyFirstCharCapitalized + : (CAPS_MODE_OFF != mCapitalizedMode); + } + + /** + * Whether or not all of the user typed chars are upper case + * @return true if all user typed chars are upper case, false otherwise + */ + public boolean isAllUpperCase() { + if (size() <= 1) { + return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED + || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFT_LOCKED; + } + return mCapsCount == size(); + } + + public boolean wasShiftedNoLock() { + return mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED + || mCapitalizedMode == CAPS_MODE_MANUAL_SHIFTED; + } + + /** + * Returns true if more than one character is upper case, otherwise returns false. + */ + public boolean isMostlyCaps() { + return mCapsCount > 1; + } + + /** + * Returns true if we have digits in the composing word. + */ + public boolean hasDigits() { + return mDigitsCount > 0; + } + + /** + * Saves the caps mode at the start of composing. + * + * WordComposer needs to know about the caps mode for several reasons. The first is, we need + * to know after the fact what the reason was, to register the correct form into the user + * history dictionary: if the word was automatically capitalized, we should insert it in + * all-lower case but if it's a manual pressing of shift, then it should be inserted as is. + * Also, batch input needs to know about the current caps mode to display correctly + * capitalized suggestions. + * @param mode the mode at the time of start + */ + public void setCapitalizedModeAtStartComposingTime(final int mode) { + mCapitalizedMode = mode; + } + + /** + * Before fetching suggestions, we don't necessarily know about the capitalized mode yet. + * + * If we don't have a composing word yet, we take a note of this mode so that we can then + * supply this information to the suggestion process. If we have a composing word, then + * the previous mode has priority over this. + * @param mode the mode just before fetching suggestions + */ + public void adviseCapitalizedModeBeforeFetchingSuggestions(final int mode) { + if (!isComposingWord()) { + mCapitalizedMode = mode; + } + } + + /** + * Returns whether the word was automatically capitalized. + * @return whether the word was automatically capitalized + */ + public boolean wasAutoCapitalized() { + return mCapitalizedMode == CAPS_MODE_AUTO_SHIFT_LOCKED + || mCapitalizedMode == CAPS_MODE_AUTO_SHIFTED; + } + + /** + * Sets the auto-correction for this word. + */ + public void setAutoCorrection(final SuggestedWordInfo autoCorrection) { + mAutoCorrection = autoCorrection; + } + + /** + * @return the auto-correction for this word, or null if none. + */ + public SuggestedWordInfo getAutoCorrectionOrNull() { + return mAutoCorrection; + } + + /** + * @return whether we started composing this word by resuming suggestion on an existing string + */ + public boolean isResumed() { + return mIsResumed; + } + + // `type' should be one of the LastComposedWord.COMMIT_TYPE_* constants above. + // committedWord should contain suggestion spans if applicable. + public LastComposedWord commitWord(final int type, final CharSequence committedWord, + final String separatorString, final NgramContext ngramContext) { + // Note: currently, we come here whenever we commit a word. If it's a MANUAL_PICK + // or a DECIDED_WORD we may cancel the commit later; otherwise, we should deactivate + // the last composed word to ensure this does not happen. + final LastComposedWord lastComposedWord = new LastComposedWord(mEvents, + mInputPointers, mTypedWordCache.toString(), committedWord, separatorString, + ngramContext, mCapitalizedMode); + mInputPointers.reset(); + if (type != LastComposedWord.COMMIT_TYPE_DECIDED_WORD + && type != LastComposedWord.COMMIT_TYPE_MANUAL_PICK) { + lastComposedWord.deactivate(); + } + mCapsCount = 0; + mDigitsCount = 0; + mIsBatchMode = false; + mCombinerChain.reset(); + mEvents.clear(); + mCodePointSize = 0; + mIsOnlyFirstCharCapitalized = false; + mCapitalizedMode = CAPS_MODE_OFF; + refreshTypedWordCache(); + mAutoCorrection = null; + mCursorPositionWithinWord = 0; + mIsResumed = false; + mRejectedBatchModeSuggestion = null; + return lastComposedWord; + } + + public void resumeSuggestionOnLastComposedWord(final LastComposedWord lastComposedWord) { + mEvents.clear(); + Collections.copy(mEvents, lastComposedWord.mEvents); + mInputPointers.set(lastComposedWord.mInputPointers); + mCombinerChain.reset(); + refreshTypedWordCache(); + mCapitalizedMode = lastComposedWord.mCapitalizedMode; + mAutoCorrection = null; // This will be filled by the next call to updateSuggestion. + mCursorPositionWithinWord = mCodePointSize; + mRejectedBatchModeSuggestion = null; + mIsResumed = true; + } + + public boolean isBatchMode() { + return mIsBatchMode; + } + + public void setRejectedBatchModeSuggestion(final String rejectedSuggestion) { + mRejectedBatchModeSuggestion = rejectedSuggestion; + } + + public String getRejectedBatchModeSuggestion() { + return mRejectedBatchModeSuggestion; + } + + @UsedForTesting + void addInputPointerForTest(int index, int keyX, int keyY) { + mInputPointers.addPointerAt(index, keyX, keyY, 0, 0); + } + + @UsedForTesting + void setTypedWordCacheForTests(String typedWordCacheForTests) { + mTypedWordCache = typedWordCacheForTests; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/WordListInfo.java b/java/src/org/kelar/inputmethod/latin/WordListInfo.java new file mode 100644 index 000000000..f75721ae2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/WordListInfo.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin; + +/** + * Information container for a word list. + */ +public final class WordListInfo { + public final String mId; + public final String mLocale; + public final String mRawChecksum; + public WordListInfo(final String id, final String locale, final String rawChecksum) { + mId = id; + mLocale = locale; + mRawChecksum = rawChecksum; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/about/AboutPreferences.java b/java/src/org/kelar/inputmethod/latin/about/AboutPreferences.java new file mode 100644 index 000000000..a9e4a9929 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/about/AboutPreferences.java @@ -0,0 +1,28 @@ +/* + * 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 org.kelar.inputmethod.latin.about; + +import android.app.Fragment; + +/** + * Placeholer class of AboutPreferences. Never use this. + */ +public final class AboutPreferences extends Fragment { + private AboutPreferences() { + // Prevents this from being instantiated + } +} diff --git a/java/src/org/kelar/inputmethod/latin/accounts/AccountStateChangedListener.java b/java/src/org/kelar/inputmethod/latin/accounts/AccountStateChangedListener.java new file mode 100644 index 000000000..4680136ae --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/accounts/AccountStateChangedListener.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.accounts; + +import androidx.annotation.NonNull; + +import javax.annotation.Nullable; + +/** + * Handles changes to account used to sign in to the keyboard. + * e.g. account switching/sign-in/sign-out from the keyboard + * user toggling the sync preference. + */ +public class AccountStateChangedListener { + + /** + * Called when the current account being used in keyboard is signed out. + * + * @param oldAccount the account that was signed out of. + */ + public static void onAccountSignedOut(@NonNull String oldAccount) { + } + + /** + * Called when the user signs-in to the keyboard. + * This may be called when the user switches accounts to sign in with a different account. + * + * @param oldAccount the previous account that was being used for sign-in. + * May be null for a fresh sign-in. + * @param newAccount the account being used for sign-in. + */ + public static void onAccountSignedIn(@Nullable String oldAccount, @NonNull String newAccount) { + } + + /** + * Called when the user toggles the sync preference. + * + * @param account the account being used for sync. + * @param syncEnabled indicates whether sync has been enabled or not. + */ + public static void onSyncPreferenceChanged(@Nullable String account, boolean syncEnabled) { + } + + /** + * Forces an immediate sync to happen. + * This should only be used for debugging purposes. + * + * @param account the account to use for sync. + */ + public static void forceSync(@Nullable String account) { + } + + /** + * Forces an immediate deletion of user's data. + * This should only be used for debugging purposes. + * + * @param account the account to use for sync. + */ + public static void forceDelete(@Nullable String account) { + } +} diff --git a/java/src/org/kelar/inputmethod/latin/accounts/AccountsChangedReceiver.java b/java/src/org/kelar/inputmethod/latin/accounts/AccountsChangedReceiver.java new file mode 100644 index 000000000..e6ca1f606 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/accounts/AccountsChangedReceiver.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.accounts; + +import android.accounts.AccountManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.settings.LocalSettingsConstants; + +/** + * {@link BroadcastReceiver} for {@link AccountManager#LOGIN_ACCOUNTS_CHANGED_ACTION}. + */ +public class AccountsChangedReceiver extends BroadcastReceiver { + static final String TAG = "AccountsChangedReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (!AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION.equals(intent.getAction())) { + Log.w(TAG, "Received unknown broadcast: " + intent); + return; + } + + // Ideally the account preference could live in a different preferences file + // that wasn't being backed up and restored, however the preference fragments + // currently only deal with the default shared preferences which is why + // separating this out into a different file is not trivial currently. + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String currentAccount = prefs.getString( + LocalSettingsConstants.PREF_ACCOUNT_NAME, null); + removeUnknownAccountFromPreference(prefs, getAccountsForLogin(context), currentAccount); + } + + /** + * Helper method to help test this receiver. + */ + @UsedForTesting + protected String[] getAccountsForLogin(Context context) { + return LoginAccountUtils.getAccountsForLogin(context); + } + + /** + * Removes the currentAccount from preferences if it's not found + * in the list of current accounts. + */ + private static void removeUnknownAccountFromPreference(final SharedPreferences prefs, + final String[] accounts, final String currentAccount) { + if (currentAccount == null) { + return; + } + for (final String account : accounts) { + if (TextUtils.equals(currentAccount, account)) { + return; + } + } + Log.i(TAG, "The current account was removed from the system: " + currentAccount); + prefs.edit() + .remove(LocalSettingsConstants.PREF_ACCOUNT_NAME) + .apply(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/accounts/AuthUtils.java b/java/src/org/kelar/inputmethod/latin/accounts/AuthUtils.java new file mode 100644 index 000000000..f5e517700 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/accounts/AuthUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.accounts; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; + +import java.io.IOException; + +/** + * Utility class that handles generation/invalidation of auth tokens in the app. + */ +public class AuthUtils { + private final AccountManager mAccountManager; + + public AuthUtils(Context context) { + mAccountManager = AccountManager.get(context); + } + + /** + * @see AccountManager#invalidateAuthToken(String, String) + */ + public void invalidateAuthToken(final String accountType, final String authToken) { + mAccountManager.invalidateAuthToken(accountType, authToken); + } + + /** + * @see AccountManager#getAuthToken( + * Account, String, Bundle, boolean, AccountManagerCallback, Handler) + */ + public AccountManagerFuture<Bundle> getAuthToken(final Account account, + final String authTokenType, final Bundle options, final boolean notifyAuthFailure, + final AccountManagerCallback<Bundle> callback, final Handler handler) { + return mAccountManager.getAuthToken(account, authTokenType, options, notifyAuthFailure, + callback, handler); + } + + /** + * @see AccountManager#blockingGetAuthToken(Account, String, boolean) + */ + public String blockingGetAuthToken(final Account account, final String authTokenType, + final boolean notifyAuthFailure) throws OperationCanceledException, + AuthenticatorException, IOException { + return mAccountManager.blockingGetAuthToken(account, authTokenType, notifyAuthFailure); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/accounts/LoginAccountUtils.java b/java/src/org/kelar/inputmethod/latin/accounts/LoginAccountUtils.java new file mode 100644 index 000000000..c99505d83 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/accounts/LoginAccountUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.accounts; + +import android.content.Context; + +import javax.annotation.Nonnull; + +/** + * Utility class for retrieving accounts that may be used for login. + */ +public class LoginAccountUtils { + /** + * This defines the type of account this class deals with. + * This account type is used when listing the accounts available on the device for login. + */ + public static final String ACCOUNT_TYPE = ""; + + private LoginAccountUtils() { + // This utility class is not publicly instantiable. + } + + /** + * Get the accounts available for login. + * + * @return an array of accounts. Empty (never null) if no accounts are available for login. + */ + @Nonnull + @SuppressWarnings("unused") + public static String[] getAccountsForLogin(final Context context) { + return new String[0]; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/define/DebugFlags.java b/java/src/org/kelar/inputmethod/latin/define/DebugFlags.java new file mode 100644 index 000000000..36235be8c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/define/DebugFlags.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.define; + +import android.content.SharedPreferences; + +public final class DebugFlags { + public static final boolean DEBUG_ENABLED = false; + + private DebugFlags() { + // This class is not publicly instantiable. + } + + @SuppressWarnings("unused") + public static void init(final SharedPreferences prefs) { + } +} diff --git a/java/src/org/kelar/inputmethod/latin/define/DecoderSpecificConstants.java b/java/src/org/kelar/inputmethod/latin/define/DecoderSpecificConstants.java new file mode 100644 index 000000000..4698d49fc --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/define/DecoderSpecificConstants.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin.define; + +/** + * Decoder specific constants for LatinIme. + */ +public class DecoderSpecificConstants { + + // Must be equal to MAX_WORD_LENGTH in native/jni/src/defines.h + public static final int DICTIONARY_MAX_WORD_LENGTH = 48; + + // (MAX_PREV_WORD_COUNT_FOR_N_GRAM + 1)-gram is supported in Java side. Needs to modify + // MAX_PREV_WORD_COUNT_FOR_N_GRAM in native/jni/src/defines.h for suggestions. + public static final int MAX_PREV_WORD_COUNT_FOR_N_GRAM = 3; + + public static final String DECODER_DICT_SUFFIX = ""; + + public static final boolean SHOULD_VERIFY_MAGIC_NUMBER = true; + public static final boolean SHOULD_VERIFY_CHECKSUM = true; + public static final boolean SHOULD_USE_DICT_VERSION = true; + public static final boolean SHOULD_AUTO_CORRECT_USING_NON_WHITE_LISTED_SUGGESTION = false; + public static final boolean SHOULD_REMOVE_PREVIOUSLY_REJECTED_SUGGESTION = true; +} diff --git a/java/src/org/kelar/inputmethod/latin/define/JniLibName.java b/java/src/org/kelar/inputmethod/latin/define/JniLibName.java new file mode 100644 index 000000000..56abeb96b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/define/JniLibName.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.define; + +public final class JniLibName { + private JniLibName() { + // This class is not publicly instantiable. + } + + public static final String JNI_LIB_NAME = "jni_latinime"; +} diff --git a/java/src/org/kelar/inputmethod/latin/define/ProductionFlags.java b/java/src/org/kelar/inputmethod/latin/define/ProductionFlags.java new file mode 100644 index 000000000..08f8d5f68 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/define/ProductionFlags.java @@ -0,0 +1,60 @@ +/* + * 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 org.kelar.inputmethod.latin.define; + +import org.kelar.inputmethod.latin.SuggestedWords; + +public final class ProductionFlags { + private ProductionFlags() { + // This class is not publicly instantiable. + } + + public static final boolean IS_HARDWARE_KEYBOARD_SUPPORTED = false; + + /** + * Include all suggestions from all dictionaries in + * {@link SuggestedWords#mRawSuggestions}. + */ + public static final boolean INCLUDE_RAW_SUGGESTIONS = false; + + /** + * When false, the metrics logging is not yet ready to be enabled. + */ + public static final boolean IS_METRICS_LOGGING_SUPPORTED = false; + + /** + * When {@code false}, the split keyboard is not yet ready to be enabled. + */ + public static final boolean IS_SPLIT_KEYBOARD_SUPPORTED = true; + + /** + * When {@code false}, account sign-in in keyboard is not yet ready to be enabled. + */ + public static final boolean ENABLE_ACCOUNT_SIGN_IN = false; + + /** + * When {@code true}, user history dictionary sync feature is ready to be enabled. + */ + public static final boolean ENABLE_USER_HISTORY_DICTIONARY_SYNC = + ENABLE_ACCOUNT_SIGN_IN && false; + + /** + * When {@code true}, the IME maintains per account {@link UserHistoryDictionary}. + */ + public static final boolean ENABLE_PER_ACCOUNT_USER_HISTORY_DICTIONARY = + ENABLE_ACCOUNT_SIGN_IN && false; +} diff --git a/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogic.java new file mode 100644 index 000000000..1263e276c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogic.java @@ -0,0 +1,2353 @@ +/* + * 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 org.kelar.inputmethod.latin.inputlogic; + +import android.graphics.Color; +import android.os.SystemClock; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.BackgroundColorSpan; +import android.text.style.SuggestionSpan; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.inputmethod.CorrectionInfo; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.compat.SuggestionSpanUtils; +import org.kelar.inputmethod.event.Event; +import org.kelar.inputmethod.event.InputTransaction; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardSwitcher; +import org.kelar.inputmethod.latin.Dictionary; +import org.kelar.inputmethod.latin.DictionaryFacilitator; +import org.kelar.inputmethod.latin.LastComposedWord; +import org.kelar.inputmethod.latin.LatinIME; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.RichInputConnection; +import org.kelar.inputmethod.latin.Suggest; +import org.kelar.inputmethod.latin.Suggest.OnGetSuggestedWordsCallback; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.WordComposer; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.settings.SettingsValues; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; +import org.kelar.inputmethod.latin.suggestions.SuggestionStripViewAccessor; +import org.kelar.inputmethod.latin.utils.AsyncResultHolder; +import org.kelar.inputmethod.latin.utils.InputTypeUtils; +import org.kelar.inputmethod.latin.utils.RecapitalizeStatus; +import org.kelar.inputmethod.latin.utils.StatsUtils; +import org.kelar.inputmethod.latin.utils.TextRange; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; + +/** + * This class manages the input logic. + */ +public final class InputLogic { + private static final String TAG = InputLogic.class.getSimpleName(); + + // TODO : Remove this member when we can. + final LatinIME mLatinIME; + private final SuggestionStripViewAccessor mSuggestionStripViewAccessor; + + // Never null. + private InputLogicHandler mInputLogicHandler = InputLogicHandler.NULL_HANDLER; + + // TODO : make all these fields private as soon as possible. + // Current space state of the input method. This can be any of the above constants. + private int mSpaceState; + // Never null + public SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); + public final Suggest mSuggest; + private final DictionaryFacilitator mDictionaryFacilitator; + + public LastComposedWord mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD; + // This has package visibility so it can be accessed from InputLogicHandler. + /* package */ final WordComposer mWordComposer; + public final RichInputConnection mConnection; + private final RecapitalizeStatus mRecapitalizeStatus = new RecapitalizeStatus(); + + private int mDeleteCount; + private long mLastKeyTime; + public final TreeSet<Long> mCurrentlyPressedHardwareKeys = new TreeSet<>(); + + // Keeps track of most recently inserted text (multi-character key) for reverting + private String mEnteredText; + + // TODO: This boolean is persistent state and causes large side effects at unexpected times. + // Find a way to remove it for readability. + private boolean mIsAutoCorrectionIndicatorOn; + private long mDoubleSpacePeriodCountdownStart; + + // The word being corrected while the cursor is in the middle of the word. + // Note: This does not have a composing span, so it must be handled separately. + private String mWordBeingCorrectedByCursor = null; + + /** + * Create a new instance of the input logic. + * @param latinIME the instance of the parent LatinIME. We should remove this when we can. + * @param suggestionStripViewAccessor an object to access the suggestion strip view. + * @param dictionaryFacilitator facilitator for getting suggestions and updating user history + * dictionary. + */ + public InputLogic(final LatinIME latinIME, + final SuggestionStripViewAccessor suggestionStripViewAccessor, + final DictionaryFacilitator dictionaryFacilitator) { + mLatinIME = latinIME; + mSuggestionStripViewAccessor = suggestionStripViewAccessor; + mWordComposer = new WordComposer(); + mConnection = new RichInputConnection(latinIME); + mInputLogicHandler = InputLogicHandler.NULL_HANDLER; + mSuggest = new Suggest(dictionaryFacilitator); + mDictionaryFacilitator = dictionaryFacilitator; + } + + /** + * Initializes the input logic for input in an editor. + * + * Call this when input starts or restarts in some editor (typically, in onStartInputView). + * + * @param combiningSpec the combining spec string for this subtype + * @param settingsValues the current settings values + */ + public void startInput(final String combiningSpec, final SettingsValues settingsValues) { + mEnteredText = null; + mWordBeingCorrectedByCursor = null; + mConnection.onStartInput(); + if (!mWordComposer.getTypedWord().isEmpty()) { + // For messaging apps that offer send button, the IME does not get the opportunity + // to capture the last word. This block should capture those uncommitted words. + // The timestamp at which it is captured is not accurate but close enough. + StatsUtils.onWordCommitUserTyped( + mWordComposer.getTypedWord(), mWordComposer.isBatchMode()); + } + mWordComposer.restartCombining(combiningSpec); + resetComposingState(true /* alsoResetLastComposedWord */); + mDeleteCount = 0; + mSpaceState = SpaceState.NONE; + mRecapitalizeStatus.disable(); // Do not perform recapitalize until the cursor is moved once + mCurrentlyPressedHardwareKeys.clear(); + mSuggestedWords = SuggestedWords.getEmptyInstance(); + // In some cases (namely, after rotation of the device) editorInfo.initialSelStart is lying + // so we try using some heuristics to find out about these and fix them. + mConnection.tryFixLyingCursorPosition(); + cancelDoubleSpacePeriodCountdown(); + if (InputLogicHandler.NULL_HANDLER == mInputLogicHandler) { + mInputLogicHandler = new InputLogicHandler(mLatinIME, this); + } else { + mInputLogicHandler.reset(); + } + + if (settingsValues.mShouldShowLxxSuggestionUi) { + mConnection.requestCursorUpdates(true /* enableMonitor */, + true /* requestImmediateCallback */); + } + } + + /** + * Call this when the subtype changes. + * @param combiningSpec the spec string for the combining rules + * @param settingsValues the current settings values + */ + public void onSubtypeChanged(final String combiningSpec, final SettingsValues settingsValues) { + finishInput(); + startInput(combiningSpec, settingsValues); + } + + /** + * Call this when the orientation changes. + * @param settingsValues the current values of the settings. + */ + public void onOrientationChange(final SettingsValues settingsValues) { + // If !isComposingWord, #commitTyped() is a no-op, but still, it's better to avoid + // the useless IPC of {begin,end}BatchEdit. + if (mWordComposer.isComposingWord()) { + mConnection.beginBatchEdit(); + // If we had a composition in progress, we need to commit the word so that the + // suggestionsSpan will be added. This will allow resuming on the same suggestions + // after rotation is finished. + commitTyped(settingsValues, LastComposedWord.NOT_A_SEPARATOR); + mConnection.endBatchEdit(); + } + } + + /** + * Clean up the input logic after input is finished. + */ + public void finishInput() { + if (mWordComposer.isComposingWord()) { + mConnection.finishComposingText(); + StatsUtils.onWordCommitUserTyped( + mWordComposer.getTypedWord(), mWordComposer.isBatchMode()); + } + resetComposingState(true /* alsoResetLastComposedWord */); + mInputLogicHandler.reset(); + } + + // Normally this class just gets out of scope after the process ends, but in unit tests, we + // create several instances of LatinIME in the same process, which results in several + // instances of InputLogic. This cleans up the associated handler so that tests don't leak + // handlers. + public void recycle() { + final InputLogicHandler inputLogicHandler = mInputLogicHandler; + mInputLogicHandler = InputLogicHandler.NULL_HANDLER; + inputLogicHandler.destroy(); + mDictionaryFacilitator.closeDictionaries(); + } + + /** + * React to a string input. + * + * This is triggered by keys that input many characters at once, like the ".com" key or + * some additional keys for example. + * + * @param settingsValues the current values of the settings. + * @param event the input event containing the data. + * @return the complete transaction object + */ + public InputTransaction onTextInput(final SettingsValues settingsValues, final Event event, + final int keyboardShiftMode, final LatinIME.UIHandler handler) { + final String rawText = event.getTextToCommit().toString(); + final InputTransaction inputTransaction = new InputTransaction(settingsValues, event, + SystemClock.uptimeMillis(), mSpaceState, + getActualCapsMode(settingsValues, keyboardShiftMode)); + mConnection.beginBatchEdit(); + if (mWordComposer.isComposingWord()) { + commitCurrentAutoCorrection(settingsValues, rawText, handler); + } else { + resetComposingState(true /* alsoResetLastComposedWord */); + } + handler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_TYPING); + final String text = performSpecificTldProcessingOnTextInput(rawText); + if (SpaceState.PHANTOM == mSpaceState) { + insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues); + } + mConnection.commitText(text, 1); + StatsUtils.onWordCommitUserTyped(mEnteredText, mWordComposer.isBatchMode()); + mConnection.endBatchEdit(); + // Space state must be updated before calling updateShiftState + mSpaceState = SpaceState.NONE; + mEnteredText = text; + mWordBeingCorrectedByCursor = null; + inputTransaction.setDidAffectContents(); + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + return inputTransaction; + } + + /** + * A suggestion was picked from the suggestion strip. + * @param settingsValues the current values of the settings. + * @param suggestionInfo the suggestion info. + * @param keyboardShiftState the shift state of the keyboard, as returned by + * {@link KeyboardSwitcher#getKeyboardShiftMode()} + * @return the complete transaction object + */ + // Called from {@link SuggestionStripView} through the {@link SuggestionStripView#Listener} + // interface + public InputTransaction onPickSuggestionManually(final SettingsValues settingsValues, + final SuggestedWordInfo suggestionInfo, final int keyboardShiftState, + final int currentKeyboardScriptId, final LatinIME.UIHandler handler) { + final SuggestedWords suggestedWords = mSuggestedWords; + final String suggestion = suggestionInfo.mWord; + // If this is a punctuation picked from the suggestion strip, pass it to onCodeInput + if (suggestion.length() == 1 && suggestedWords.isPunctuationSuggestions()) { + // We still want to log a suggestion click. + StatsUtils.onPickSuggestionManually( + mSuggestedWords, suggestionInfo, mDictionaryFacilitator); + // Word separators are suggested before the user inputs something. + // Rely on onCodeInput to do the complicated swapping/stripping logic consistently. + final Event event = Event.createPunctuationSuggestionPickedEvent(suggestionInfo); + return onCodeInput(settingsValues, event, keyboardShiftState, + currentKeyboardScriptId, handler); + } + + final Event event = Event.createSuggestionPickedEvent(suggestionInfo); + final InputTransaction inputTransaction = new InputTransaction(settingsValues, + event, SystemClock.uptimeMillis(), mSpaceState, keyboardShiftState); + // Manual pick affects the contents of the editor, so we take note of this. It's important + // for the sequence of language switching. + inputTransaction.setDidAffectContents(); + mConnection.beginBatchEdit(); + if (SpaceState.PHANTOM == mSpaceState && suggestion.length() > 0 + // In the batch input mode, a manually picked suggested word should just replace + // the current batch input text and there is no need for a phantom space. + && !mWordComposer.isBatchMode()) { + final int firstChar = Character.codePointAt(suggestion, 0); + if (!settingsValues.isWordSeparator(firstChar) + || settingsValues.isUsuallyPrecededBySpace(firstChar)) { + insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues); + } + } + + // TODO: We should not need the following branch. We should be able to take the same + // code path as for other kinds, use commitChosenWord, and do everything normally. We will + // however need to reset the suggestion strip right away, because we know we can't take + // the risk of calling commitCompletion twice because we don't know how the app will react. + if (suggestionInfo.isKindOf(SuggestedWordInfo.KIND_APP_DEFINED)) { + mSuggestedWords = SuggestedWords.getEmptyInstance(); + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + resetComposingState(true /* alsoResetLastComposedWord */); + mConnection.commitCompletion(suggestionInfo.mApplicationSpecifiedCompletionInfo); + mConnection.endBatchEdit(); + return inputTransaction; + } + + commitChosenWord(settingsValues, suggestion, LastComposedWord.COMMIT_TYPE_MANUAL_PICK, + LastComposedWord.NOT_A_SEPARATOR); + mConnection.endBatchEdit(); + // Don't allow cancellation of manual pick + mLastComposedWord.deactivate(); + // Space state must be updated before calling updateShiftState + mSpaceState = SpaceState.PHANTOM; + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + + // If we're not showing the "Touch again to save", then update the suggestion strip. + // That's going to be predictions (or punctuation suggestions), so INPUT_STYLE_NONE. + handler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_NONE); + + StatsUtils.onPickSuggestionManually( + mSuggestedWords, suggestionInfo, mDictionaryFacilitator); + StatsUtils.onWordCommitSuggestionPickedManually( + suggestionInfo.mWord, mWordComposer.isBatchMode()); + return inputTransaction; + } + + /** + * Consider an update to the cursor position. Evaluate whether this update has happened as + * part of normal typing or whether it was an explicit cursor move by the user. In any case, + * do the necessary adjustments. + * @param oldSelStart old selection start + * @param oldSelEnd old selection end + * @param newSelStart new selection start + * @param newSelEnd new selection end + * @param settingsValues the current values of the settings. + * @return whether the cursor has moved as a result of user interaction. + */ + public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, + final int newSelStart, final int newSelEnd, final SettingsValues settingsValues) { + if (mConnection.isBelatedExpectedUpdate(oldSelStart, newSelStart, oldSelEnd, newSelEnd)) { + return false; + } + // TODO: the following is probably better done in resetEntireInputState(). + // it should only happen when the cursor moved, and the very purpose of the + // test below is to narrow down whether this happened or not. Likewise with + // the call to updateShiftState. + // We set this to NONE because after a cursor move, we don't want the space + // state-related special processing to kick in. + mSpaceState = SpaceState.NONE; + + final boolean selectionChangedOrSafeToReset = + oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection changed + || !mWordComposer.isComposingWord(); // safe to reset + final boolean hasOrHadSelection = (oldSelStart != oldSelEnd || newSelStart != newSelEnd); + final int moveAmount = newSelStart - oldSelStart; + // As an added small gift from the framework, it happens upon rotation when there + // is a selection that we get a wrong cursor position delivered to startInput() that + // does not get reflected in the oldSel{Start,End} parameters to the next call to + // onUpdateSelection. In this case, we may have set a composition, and when we're here + // we realize we shouldn't have. In theory, in this case, selectionChangedOrSafeToReset + // should be true, but that is if the framework had taken that wrong cursor position + // into account, which means we have to reset the entire composing state whenever there + // is or was a selection regardless of whether it changed or not. + if (hasOrHadSelection || !settingsValues.needsToLookupSuggestions() + || (selectionChangedOrSafeToReset + && !mWordComposer.moveCursorByAndReturnIfInsideComposingWord(moveAmount))) { + // If we are composing a word and moving the cursor, we would want to set a + // suggestion span for recorrection to work correctly. Unfortunately, that + // would involve the keyboard committing some new text, which would move the + // cursor back to where it was. Latin IME could then fix the position of the cursor + // again, but the asynchronous nature of the calls results in this wreaking havoc + // with selection on double tap and the like. + // Another option would be to send suggestions each time we set the composing + // text, but that is probably too expensive to do, so we decided to leave things + // as is. + // Also, we're posting a resume suggestions message, and this will update the + // suggestions strip in a few milliseconds, so if we cleared the suggestion strip here + // we'd have the suggestion strip noticeably janky. To avoid that, we don't clear + // it here, which means we'll keep outdated suggestions for a split second but the + // visual result is better. + resetEntireInputState(newSelStart, newSelEnd, false /* clearSuggestionStrip */); + // If the user is in the middle of correcting a word, we should learn it before moving + // the cursor away. + if (!TextUtils.isEmpty(mWordBeingCorrectedByCursor)) { + final int timeStampInSeconds = (int)TimeUnit.MILLISECONDS.toSeconds( + System.currentTimeMillis()); + performAdditionToUserHistoryDictionary(settingsValues, mWordBeingCorrectedByCursor, + NgramContext.EMPTY_PREV_WORDS_INFO); + } + } else { + // resetEntireInputState calls resetCachesUponCursorMove, but forcing the + // composition to end. But in all cases where we don't reset the entire input + // state, we still want to tell the rich input connection about the new cursor + // position so that it can update its caches. + mConnection.resetCachesUponCursorMoveAndReturnSuccess( + newSelStart, newSelEnd, false /* shouldFinishComposition */); + } + + // The cursor has been moved : we now accept to perform recapitalization + mRecapitalizeStatus.enable(); + // We moved the cursor. If we are touching a word, we need to resume suggestion. + mLatinIME.mHandler.postResumeSuggestions(true /* shouldDelay */); + // Stop the last recapitalization, if started. + mRecapitalizeStatus.stop(); + mWordBeingCorrectedByCursor = null; + return true; + } + + /** + * React to a code input. It may be a code point to insert, or a symbolic value that influences + * the keyboard behavior. + * + * Typically, this is called whenever a key is pressed on the software keyboard. This is not + * the entry point for gesture input; see the onBatchInput* family of functions for this. + * + * @param settingsValues the current settings values. + * @param event the event to handle. + * @param keyboardShiftMode the current shift mode of the keyboard, as returned by + * {@link KeyboardSwitcher#getKeyboardShiftMode()} + * @return the complete transaction object + */ + public InputTransaction onCodeInput(final SettingsValues settingsValues, + @Nonnull final Event event, final int keyboardShiftMode, + final int currentKeyboardScriptId, final LatinIME.UIHandler handler) { + mWordBeingCorrectedByCursor = null; + final Event processedEvent = mWordComposer.processEvent(event); + final InputTransaction inputTransaction = new InputTransaction(settingsValues, + processedEvent, SystemClock.uptimeMillis(), mSpaceState, + getActualCapsMode(settingsValues, keyboardShiftMode)); + if (processedEvent.mKeyCode != Constants.CODE_DELETE + || inputTransaction.mTimestamp > mLastKeyTime + Constants.LONG_PRESS_MILLISECONDS) { + mDeleteCount = 0; + } + mLastKeyTime = inputTransaction.mTimestamp; + mConnection.beginBatchEdit(); + if (!mWordComposer.isComposingWord()) { + // TODO: is this useful? It doesn't look like it should be done here, but rather after + // a word is committed. + mIsAutoCorrectionIndicatorOn = false; + } + + // TODO: Consolidate the double-space period timer, mLastKeyTime, and the space state. + if (processedEvent.mCodePoint != Constants.CODE_SPACE) { + cancelDoubleSpacePeriodCountdown(); + } + + Event currentEvent = processedEvent; + while (null != currentEvent) { + if (currentEvent.isConsumed()) { + handleConsumedEvent(currentEvent, inputTransaction); + } else if (currentEvent.isFunctionalKeyEvent()) { + handleFunctionalEvent(currentEvent, inputTransaction, currentKeyboardScriptId, + handler); + } else { + handleNonFunctionalEvent(currentEvent, inputTransaction, handler); + } + currentEvent = currentEvent.mNextEvent; + } + // Try to record the word being corrected when the user enters a word character or + // the backspace key. + if (!mConnection.hasSlowInputConnection() && !mWordComposer.isComposingWord() + && (settingsValues.isWordCodePoint(processedEvent.mCodePoint) || + processedEvent.mKeyCode == Constants.CODE_DELETE)) { + mWordBeingCorrectedByCursor = getWordAtCursor( + settingsValues, currentKeyboardScriptId); + } + if (!inputTransaction.didAutoCorrect() && processedEvent.mKeyCode != Constants.CODE_SHIFT + && processedEvent.mKeyCode != Constants.CODE_CAPSLOCK + && processedEvent.mKeyCode != Constants.CODE_SWITCH_ALPHA_SYMBOL) + mLastComposedWord.deactivate(); + if (Constants.CODE_DELETE != processedEvent.mKeyCode) { + mEnteredText = null; + } + mConnection.endBatchEdit(); + return inputTransaction; + } + + public void onStartBatchInput(final SettingsValues settingsValues, + final KeyboardSwitcher keyboardSwitcher, final LatinIME.UIHandler handler) { + mWordBeingCorrectedByCursor = null; + mInputLogicHandler.onStartBatchInput(); + handler.showGesturePreviewAndSuggestionStrip( + SuggestedWords.getEmptyInstance(), false /* dismissGestureFloatingPreviewText */); + handler.cancelUpdateSuggestionStrip(); + ++mAutoCommitSequenceNumber; + mConnection.beginBatchEdit(); + if (mWordComposer.isComposingWord()) { + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { + // If we are in the middle of a recorrection, we need to commit the recorrection + // first so that we can insert the batch input at the current cursor position. + // We also need to unlearn the original word that is now being corrected. + unlearnWord(mWordComposer.getTypedWord(), settingsValues, + Constants.EVENT_BACKSPACE); + resetEntireInputState(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd(), true /* clearSuggestionStrip */); + } else if (mWordComposer.isSingleLetter()) { + // We auto-correct the previous (typed, not gestured) string iff it's one character + // long. The reason for this is, even in the middle of gesture typing, you'll still + // tap one-letter words and you want them auto-corrected (typically, "i" in English + // should become "I"). However for any longer word, we assume that the reason for + // tapping probably is that the word you intend to type is not in the dictionary, + // so we do not attempt to correct, on the assumption that if that was a dictionary + // word, the user would probably have gestured instead. + commitCurrentAutoCorrection(settingsValues, LastComposedWord.NOT_A_SEPARATOR, + handler); + } else { + commitTyped(settingsValues, LastComposedWord.NOT_A_SEPARATOR); + } + } + final int codePointBeforeCursor = mConnection.getCodePointBeforeCursor(); + if (Character.isLetterOrDigit(codePointBeforeCursor) + || settingsValues.isUsuallyFollowedBySpace(codePointBeforeCursor)) { + final boolean autoShiftHasBeenOverriden = keyboardSwitcher.getKeyboardShiftMode() != + getCurrentAutoCapsState(settingsValues); + mSpaceState = SpaceState.PHANTOM; + if (!autoShiftHasBeenOverriden) { + // When we change the space state, we need to update the shift state of the + // keyboard unless it has been overridden manually. This is happening for example + // after typing some letters and a period, then gesturing; the keyboard is not in + // caps mode yet, but since a gesture is starting, it should go in caps mode, + // unless the user explictly said it should not. + keyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState(settingsValues), + getCurrentRecapitalizeState()); + } + } + mConnection.endBatchEdit(); + mWordComposer.setCapitalizedModeAtStartComposingTime( + getActualCapsMode(settingsValues, keyboardSwitcher.getKeyboardShiftMode())); + } + + /* The sequence number member is only used in onUpdateBatchInput. It is increased each time + * auto-commit happens. The reason we need this is, when auto-commit happens we trim the + * input pointers that are held in a singleton, and to know how much to trim we rely on the + * results of the suggestion process that is held in mSuggestedWords. + * However, the suggestion process is asynchronous, and sometimes we may enter the + * onUpdateBatchInput method twice without having recomputed suggestions yet, or having + * received new suggestions generated from not-yet-trimmed input pointers. In this case, the + * mIndexOfTouchPointOfSecondWords member will be out of date, and we must not use it lest we + * remove an unrelated number of pointers (possibly even more than are left in the input + * pointers, leading to a crash). + * To avoid that, we increase the sequence number each time we auto-commit and trim the + * input pointers, and we do not use any suggested words that have been generated with an + * earlier sequence number. + */ + private int mAutoCommitSequenceNumber = 1; + public void onUpdateBatchInput(final InputPointers batchPointers) { + mInputLogicHandler.onUpdateBatchInput(batchPointers, mAutoCommitSequenceNumber); + } + + public void onEndBatchInput(final InputPointers batchPointers) { + mInputLogicHandler.updateTailBatchInput(batchPointers, mAutoCommitSequenceNumber); + ++mAutoCommitSequenceNumber; + } + + public void onCancelBatchInput(final LatinIME.UIHandler handler) { + mInputLogicHandler.onCancelBatchInput(); + handler.showGesturePreviewAndSuggestionStrip( + SuggestedWords.getEmptyInstance(), true /* dismissGestureFloatingPreviewText */); + } + + // TODO: on the long term, this method should become private, but it will be difficult. + // Especially, how do we deal with InputMethodService.onDisplayCompletions? + public void setSuggestedWords(final SuggestedWords suggestedWords) { + if (!suggestedWords.isEmpty()) { + final SuggestedWordInfo suggestedWordInfo; + if (suggestedWords.mWillAutoCorrect) { + suggestedWordInfo = suggestedWords.getInfo(SuggestedWords.INDEX_OF_AUTO_CORRECTION); + } else { + // We can't use suggestedWords.getWord(SuggestedWords.INDEX_OF_TYPED_WORD) + // because it may differ from mWordComposer.mTypedWord. + suggestedWordInfo = suggestedWords.mTypedWordInfo; + } + mWordComposer.setAutoCorrection(suggestedWordInfo); + } + mSuggestedWords = suggestedWords; + final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect; + + // Put a blue underline to a word in TextView which will be auto-corrected. + if (mIsAutoCorrectionIndicatorOn != newAutoCorrectionIndicator + && mWordComposer.isComposingWord()) { + mIsAutoCorrectionIndicatorOn = newAutoCorrectionIndicator; + final CharSequence textWithUnderline = + getTextWithUnderline(mWordComposer.getTypedWord()); + // TODO: when called from an updateSuggestionStrip() call that results from a posted + // message, this is called outside any batch edit. Potentially, this may result in some + // janky flickering of the screen, although the display speed makes it unlikely in + // the practice. + setComposingTextInternal(textWithUnderline, 1); + } + } + + /** + * Handle a consumed event. + * + * Consumed events represent events that have already been consumed, typically by the + * combining chain. + * + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleConsumedEvent(final Event event, final InputTransaction inputTransaction) { + // A consumed event may have text to commit and an update to the composing state, so + // we evaluate both. With some combiners, it's possible than an event contains both + // and we enter both of the following if clauses. + final CharSequence textToCommit = event.getTextToCommit(); + if (!TextUtils.isEmpty(textToCommit)) { + mConnection.commitText(textToCommit, 1); + inputTransaction.setDidAffectContents(); + } + if (mWordComposer.isComposingWord()) { + setComposingTextInternal(mWordComposer.getTypedWord(), 1); + inputTransaction.setDidAffectContents(); + inputTransaction.setRequiresUpdateSuggestions(); + } + } + + /** + * Handle a functional key event. + * + * A functional event is a special key, like delete, shift, emoji, or the settings key. + * Non-special keys are those that generate a single code point. + * This includes all letters, digits, punctuation, separators, emoji. It excludes keys that + * manage keyboard-related stuff like shift, language switch, settings, layout switch, or + * any key that results in multiple code points like the ".com" key. + * + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleFunctionalEvent(final Event event, final InputTransaction inputTransaction, + final int currentKeyboardScriptId, final LatinIME.UIHandler handler) { + switch (event.mKeyCode) { + case Constants.CODE_DELETE: + handleBackspaceEvent(event, inputTransaction, currentKeyboardScriptId); + // Backspace is a functional key, but it affects the contents of the editor. + inputTransaction.setDidAffectContents(); + break; + case Constants.CODE_SHIFT: + performRecapitalization(inputTransaction.mSettingsValues); + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + if (mSuggestedWords.isPrediction()) { + inputTransaction.setRequiresUpdateSuggestions(); + } + break; + case Constants.CODE_CAPSLOCK: + // Note: Changing keyboard to shift lock state is handled in + // {@link KeyboardSwitcher#onEvent(Event)}. + break; + case Constants.CODE_SYMBOL_SHIFT: + // Note: Calling back to the keyboard on the symbol Shift key is handled in + // {@link #onPressKey(int,int,boolean)} and {@link #onReleaseKey(int,boolean)}. + break; + case Constants.CODE_SWITCH_ALPHA_SYMBOL: + // Note: Calling back to the keyboard on symbol key is handled in + // {@link #onPressKey(int,int,boolean)} and {@link #onReleaseKey(int,boolean)}. + break; + case Constants.CODE_SETTINGS: + onSettingsKeyPressed(); + break; + case Constants.CODE_SHORTCUT: + // We need to switch to the shortcut IME. This is handled by LatinIME since the + // input logic has no business with IME switching. + break; + case Constants.CODE_ACTION_NEXT: + performEditorAction(EditorInfo.IME_ACTION_NEXT); + break; + case Constants.CODE_ACTION_PREVIOUS: + performEditorAction(EditorInfo.IME_ACTION_PREVIOUS); + break; + case Constants.CODE_LANGUAGE_SWITCH: + handleLanguageSwitchKey(); + break; + case Constants.CODE_EMOJI: + // Note: Switching emoji keyboard is being handled in + // {@link KeyboardState#onEvent(Event,int)}. + break; + case Constants.CODE_ALPHA_FROM_EMOJI: + // Note: Switching back from Emoji keyboard to the main keyboard is being + // handled in {@link KeyboardState#onEvent(Event,int)}. + break; + case Constants.CODE_SHIFT_ENTER: + final Event tmpEvent = Event.createSoftwareKeypressEvent(Constants.CODE_ENTER, + event.mKeyCode, event.mX, event.mY, event.isKeyRepeat()); + handleNonSpecialCharacterEvent(tmpEvent, inputTransaction, handler); + // Shift + Enter is treated as a functional key but it results in adding a new + // line, so that does affect the contents of the editor. + inputTransaction.setDidAffectContents(); + break; + default: + throw new RuntimeException("Unknown key code : " + event.mKeyCode); + } + } + + /** + * Handle an event that is not a functional event. + * + * These events are generally events that cause input, but in some cases they may do other + * things like trigger an editor action. + * + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleNonFunctionalEvent(final Event event, + final InputTransaction inputTransaction, + final LatinIME.UIHandler handler) { + inputTransaction.setDidAffectContents(); + switch (event.mCodePoint) { + case Constants.CODE_ENTER: + final EditorInfo editorInfo = getCurrentInputEditorInfo(); + final int imeOptionsActionId = + InputTypeUtils.getImeOptionsActionIdFromEditorInfo(editorInfo); + if (InputTypeUtils.IME_ACTION_CUSTOM_LABEL == imeOptionsActionId) { + // Either we have an actionLabel and we should performEditorAction with + // actionId regardless of its value. + performEditorAction(editorInfo.actionId); + } else if (EditorInfo.IME_ACTION_NONE != imeOptionsActionId) { + // We didn't have an actionLabel, but we had another action to execute. + // EditorInfo.IME_ACTION_NONE explicitly means no action. In contrast, + // EditorInfo.IME_ACTION_UNSPECIFIED is the default value for an action, so it + // means there should be an action and the app didn't bother to set a specific + // code for it - presumably it only handles one. It does not have to be treated + // in any specific way: anything that is not IME_ACTION_NONE should be sent to + // performEditorAction. + performEditorAction(imeOptionsActionId); + } else { + // No action label, and the action from imeOptions is NONE: this is a regular + // enter key that should input a carriage return. + handleNonSpecialCharacterEvent(event, inputTransaction, handler); + } + break; + default: + handleNonSpecialCharacterEvent(event, inputTransaction, handler); + break; + } + } + + /** + * Handle inputting a code point to the editor. + * + * Non-special keys are those that generate a single code point. + * This includes all letters, digits, punctuation, separators, emoji. It excludes keys that + * manage keyboard-related stuff like shift, language switch, settings, layout switch, or + * any key that results in multiple code points like the ".com" key. + * + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleNonSpecialCharacterEvent(final Event event, + final InputTransaction inputTransaction, + final LatinIME.UIHandler handler) { + final int codePoint = event.mCodePoint; + mSpaceState = SpaceState.NONE; + if (inputTransaction.mSettingsValues.isWordSeparator(codePoint) + || Character.getType(codePoint) == Character.OTHER_SYMBOL) { + handleSeparatorEvent(event, inputTransaction, handler); + } else { + if (SpaceState.PHANTOM == inputTransaction.mSpaceState) { + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { + // If we are in the middle of a recorrection, we need to commit the recorrection + // first so that we can insert the character at the current cursor position. + // We also need to unlearn the original word that is now being corrected. + unlearnWord(mWordComposer.getTypedWord(), inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + resetEntireInputState(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd(), true /* clearSuggestionStrip */); + } else { + commitTyped(inputTransaction.mSettingsValues, LastComposedWord.NOT_A_SEPARATOR); + } + } + handleNonSeparatorEvent(event, inputTransaction.mSettingsValues, inputTransaction); + } + } + + /** + * Handle a non-separator. + * @param event The event to handle. + * @param settingsValues The current settings values. + * @param inputTransaction The transaction in progress. + */ + private void handleNonSeparatorEvent(final Event event, final SettingsValues settingsValues, + final InputTransaction inputTransaction) { + final int codePoint = event.mCodePoint; + // TODO: refactor this method to stop flipping isComposingWord around all the time, and + // make it shorter (possibly cut into several pieces). Also factor + // handleNonSpecialCharacterEvent which has the same name as other handle* methods but is + // not the same. + boolean isComposingWord = mWordComposer.isComposingWord(); + + // TODO: remove isWordConnector() and use isUsuallyFollowedBySpace() instead. + // See onStartBatchInput() to see how to do it. + if (SpaceState.PHANTOM == inputTransaction.mSpaceState + && !settingsValues.isWordConnector(codePoint)) { + if (isComposingWord) { + // Validity check + throw new RuntimeException("Should not be composing here"); + } + insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues); + } + + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { + // If we are in the middle of a recorrection, we need to commit the recorrection + // first so that we can insert the character at the current cursor position. + // We also need to unlearn the original word that is now being corrected. + unlearnWord(mWordComposer.getTypedWord(), inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + resetEntireInputState(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd(), true /* clearSuggestionStrip */); + isComposingWord = false; + } + // We want to find out whether to start composing a new word with this character. If so, + // we need to reset the composing state and switch isComposingWord. The order of the + // tests is important for good performance. + // We only start composing if we're not already composing. + if (!isComposingWord + // We only start composing if this is a word code point. Essentially that means it's a + // a letter or a word connector. + && settingsValues.isWordCodePoint(codePoint) + // We never go into composing state if suggestions are not requested. + && settingsValues.needsToLookupSuggestions() && + // In languages with spaces, we only start composing a word when we are not already + // touching a word. In languages without spaces, the above conditions are sufficient. + // NOTE: If the InputConnection is slow, we skip the text-after-cursor check since it + // can incur a very expensive getTextAfterCursor() lookup, potentially making the + // keyboard UI slow and non-responsive. + // TODO: Cache the text after the cursor so we don't need to go to the InputConnection + // each time. We are already doing this for getTextBeforeCursor(). + (!settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + || !mConnection.isCursorTouchingWord(settingsValues.mSpacingAndPunctuations, + !mConnection.hasSlowInputConnection() /* checkTextAfter */))) { + // Reset entirely the composing state anyway, then start composing a new word unless + // the character is a word connector. The idea here is, word connectors are not + // separators and they should be treated as normal characters, except in the first + // position where they should not start composing a word. + isComposingWord = !settingsValues.mSpacingAndPunctuations.isWordConnector(codePoint); + // Here we don't need to reset the last composed word. It will be reset + // when we commit this one, if we ever do; if on the other hand we backspace + // it entirely and resume suggestions on the previous word, we'd like to still + // have touch coordinates for it. + resetComposingState(false /* alsoResetLastComposedWord */); + } + if (isComposingWord) { + mWordComposer.applyProcessedEvent(event); + // If it's the first letter, make note of auto-caps state + if (mWordComposer.isSingleLetter()) { + mWordComposer.setCapitalizedModeAtStartComposingTime(inputTransaction.mShiftState); + } + setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1); + } else { + final boolean swapWeakSpace = tryStripSpaceAndReturnWhetherShouldSwapInstead(event, + inputTransaction); + + if (swapWeakSpace && trySwapSwapperAndSpace(event, inputTransaction)) { + mSpaceState = SpaceState.WEAK; + } else { + sendKeyCodePoint(settingsValues, codePoint); + } + } + inputTransaction.setRequiresUpdateSuggestions(); + } + + /** + * Handle input of a separator code point. + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleSeparatorEvent(final Event event, final InputTransaction inputTransaction, + final LatinIME.UIHandler handler) { + final int codePoint = event.mCodePoint; + final SettingsValues settingsValues = inputTransaction.mSettingsValues; + final boolean wasComposingWord = mWordComposer.isComposingWord(); + // We avoid sending spaces in languages without spaces if we were composing. + final boolean shouldAvoidSendingCode = Constants.CODE_SPACE == codePoint + && !settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + && wasComposingWord; + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { + // If we are in the middle of a recorrection, we need to commit the recorrection + // first so that we can insert the separator at the current cursor position. + // We also need to unlearn the original word that is now being corrected. + unlearnWord(mWordComposer.getTypedWord(), inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + resetEntireInputState(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd(), true /* clearSuggestionStrip */); + } + // isComposingWord() may have changed since we stored wasComposing + if (mWordComposer.isComposingWord()) { + if (settingsValues.mAutoCorrectionEnabledPerUserSettings) { + final String separator = shouldAvoidSendingCode ? LastComposedWord.NOT_A_SEPARATOR + : StringUtils.newSingleCodePointString(codePoint); + commitCurrentAutoCorrection(settingsValues, separator, handler); + inputTransaction.setDidAutoCorrect(); + } else { + commitTyped(settingsValues, + StringUtils.newSingleCodePointString(codePoint)); + } + } + + final boolean swapWeakSpace = tryStripSpaceAndReturnWhetherShouldSwapInstead(event, + inputTransaction); + + final boolean isInsideDoubleQuoteOrAfterDigit = Constants.CODE_DOUBLE_QUOTE == codePoint + && mConnection.isInsideDoubleQuoteOrAfterDigit(); + + final boolean needsPrecedingSpace; + if (SpaceState.PHANTOM != inputTransaction.mSpaceState) { + needsPrecedingSpace = false; + } else if (Constants.CODE_DOUBLE_QUOTE == codePoint) { + // Double quotes behave like they are usually preceded by space iff we are + // not inside a double quote or after a digit. + needsPrecedingSpace = !isInsideDoubleQuoteOrAfterDigit; + } else if (settingsValues.mSpacingAndPunctuations.isClusteringSymbol(codePoint) + && settingsValues.mSpacingAndPunctuations.isClusteringSymbol( + mConnection.getCodePointBeforeCursor())) { + needsPrecedingSpace = false; + } else { + needsPrecedingSpace = settingsValues.isUsuallyPrecededBySpace(codePoint); + } + + if (needsPrecedingSpace) { + insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues); + } + + if (tryPerformDoubleSpacePeriod(event, inputTransaction)) { + mSpaceState = SpaceState.DOUBLE; + inputTransaction.setRequiresUpdateSuggestions(); + StatsUtils.onDoubleSpacePeriod(); + } else if (swapWeakSpace && trySwapSwapperAndSpace(event, inputTransaction)) { + mSpaceState = SpaceState.SWAP_PUNCTUATION; + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + } else if (Constants.CODE_SPACE == codePoint) { + if (!mSuggestedWords.isPunctuationSuggestions()) { + mSpaceState = SpaceState.WEAK; + } + + startDoubleSpacePeriodCountdown(inputTransaction); + if (wasComposingWord || mSuggestedWords.isEmpty()) { + inputTransaction.setRequiresUpdateSuggestions(); + } + + if (!shouldAvoidSendingCode) { + sendKeyCodePoint(settingsValues, codePoint); + } + } else { + if ((SpaceState.PHANTOM == inputTransaction.mSpaceState + && settingsValues.isUsuallyFollowedBySpace(codePoint)) + || (Constants.CODE_DOUBLE_QUOTE == codePoint + && isInsideDoubleQuoteOrAfterDigit)) { + // If we are in phantom space state, and the user presses a separator, we want to + // stay in phantom space state so that the next keypress has a chance to add the + // space. For example, if I type "Good dat", pick "day" from the suggestion strip + // then insert a comma and go on to typing the next word, I want the space to be + // inserted automatically before the next word, the same way it is when I don't + // input the comma. A double quote behaves like it's usually followed by space if + // we're inside a double quote. + // The case is a little different if the separator is a space stripper. Such a + // separator does not normally need a space on the right (that's the difference + // between swappers and strippers), so we should not stay in phantom space state if + // the separator is a stripper. Hence the additional test above. + mSpaceState = SpaceState.PHANTOM; + } + + sendKeyCodePoint(settingsValues, codePoint); + + // Set punctuation right away. onUpdateSelection will fire but tests whether it is + // already displayed or not, so it's okay. + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + } + + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + } + + /** + * Handle a press on the backspace key. + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + */ + private void handleBackspaceEvent(final Event event, final InputTransaction inputTransaction, + final int currentKeyboardScriptId) { + mSpaceState = SpaceState.NONE; + mDeleteCount++; + + // In many cases after backspace, we need to update the shift state. Normally we need + // to do this right away to avoid the shift state being out of date in case the user types + // backspace then some other character very fast. However, in the case of backspace key + // repeat, this can lead to flashiness when the cursor flies over positions where the + // shift state should be updated, so if this is a key repeat, we update after a small delay. + // Then again, even in the case of a key repeat, if the cursor is at start of text, it + // can't go any further back, so we can update right away even if it's a key repeat. + final int shiftUpdateKind = + event.isKeyRepeat() && mConnection.getExpectedSelectionStart() > 0 + ? InputTransaction.SHIFT_UPDATE_LATER : InputTransaction.SHIFT_UPDATE_NOW; + inputTransaction.requireShiftUpdate(shiftUpdateKind); + + if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) { + // If we are in the middle of a recorrection, we need to commit the recorrection + // first so that we can remove the character at the current cursor position. + // We also need to unlearn the original word that is now being corrected. + unlearnWord(mWordComposer.getTypedWord(), inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + resetEntireInputState(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd(), true /* clearSuggestionStrip */); + // When we exit this if-clause, mWordComposer.isComposingWord() will return false. + } + if (mWordComposer.isComposingWord()) { + if (mWordComposer.isBatchMode()) { + final String rejectedSuggestion = mWordComposer.getTypedWord(); + mWordComposer.reset(); + mWordComposer.setRejectedBatchModeSuggestion(rejectedSuggestion); + if (!TextUtils.isEmpty(rejectedSuggestion)) { + unlearnWord(rejectedSuggestion, inputTransaction.mSettingsValues, + Constants.EVENT_REJECTION); + } + StatsUtils.onBackspaceWordDelete(rejectedSuggestion.length()); + } else { + mWordComposer.applyProcessedEvent(event); + StatsUtils.onBackspacePressed(1); + } + if (mWordComposer.isComposingWord()) { + setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1); + } else { + mConnection.commitText("", 1); + } + inputTransaction.setRequiresUpdateSuggestions(); + } else { + if (mLastComposedWord.canRevertCommit()) { + final String lastComposedWord = mLastComposedWord.mTypedWord; + revertCommit(inputTransaction, inputTransaction.mSettingsValues); + StatsUtils.onRevertAutoCorrect(); + StatsUtils.onWordCommitUserTyped(lastComposedWord, mWordComposer.isBatchMode()); + // Restart suggestions when backspacing into a reverted word. This is required for + // the final corrected word to be learned, as learning only occurs when suggestions + // are active. + // + // Note: restartSuggestionsOnWordTouchedByCursor is already called for normal + // (non-revert) backspace handling. + if (inputTransaction.mSettingsValues.isSuggestionsEnabledPerUserSettings() + && inputTransaction.mSettingsValues.mSpacingAndPunctuations + .mCurrentLanguageHasSpaces + && !mConnection.isCursorFollowedByWordCharacter( + inputTransaction.mSettingsValues.mSpacingAndPunctuations)) { + restartSuggestionsOnWordTouchedByCursor(inputTransaction.mSettingsValues, + false /* forStartInput */, currentKeyboardScriptId); + } + return; + } + if (mEnteredText != null && mConnection.sameAsTextBeforeCursor(mEnteredText)) { + // Cancel multi-character input: remove the text we just entered. + // This is triggered on backspace after a key that inputs multiple characters, + // like the smiley key or the .com key. + mConnection.deleteTextBeforeCursor(mEnteredText.length()); + StatsUtils.onDeleteMultiCharInput(mEnteredText.length()); + mEnteredText = null; + // If we have mEnteredText, then we know that mHasUncommittedTypedChars == false. + // In addition we know that spaceState is false, and that we should not be + // reverting any autocorrect at this point. So we can safely return. + return; + } + if (SpaceState.DOUBLE == inputTransaction.mSpaceState) { + cancelDoubleSpacePeriodCountdown(); + if (mConnection.revertDoubleSpacePeriod( + inputTransaction.mSettingsValues.mSpacingAndPunctuations)) { + // No need to reset mSpaceState, it has already be done (that's why we + // receive it as a parameter) + inputTransaction.setRequiresUpdateSuggestions(); + mWordComposer.setCapitalizedModeAtStartComposingTime( + WordComposer.CAPS_MODE_OFF); + StatsUtils.onRevertDoubleSpacePeriod(); + return; + } + } else if (SpaceState.SWAP_PUNCTUATION == inputTransaction.mSpaceState) { + if (mConnection.revertSwapPunctuation()) { + StatsUtils.onRevertSwapPunctuation(); + // Likewise + return; + } + } + + boolean hasUnlearnedWordBeingDeleted = false; + + // No cancelling of commit/double space/swap: we have a regular backspace. + // We should backspace one char and restart suggestion if at the end of a word. + if (mConnection.hasSelection()) { + // If there is a selection, remove it. + // We also need to unlearn the selected text. + final CharSequence selection = mConnection.getSelectedText(0 /* 0 for no styles */); + if (!TextUtils.isEmpty(selection)) { + unlearnWord(selection.toString(), inputTransaction.mSettingsValues, + Constants.EVENT_BACKSPACE); + hasUnlearnedWordBeingDeleted = true; + } + final int numCharsDeleted = mConnection.getExpectedSelectionEnd() + - mConnection.getExpectedSelectionStart(); + mConnection.setSelection(mConnection.getExpectedSelectionEnd(), + mConnection.getExpectedSelectionEnd()); + mConnection.deleteTextBeforeCursor(numCharsDeleted); + StatsUtils.onBackspaceSelectedText(numCharsDeleted); + } else { + // There is no selection, just delete one character. + if (inputTransaction.mSettingsValues.isBeforeJellyBean() + || inputTransaction.mSettingsValues.mInputAttributes.isTypeNull() + || Constants.NOT_A_CURSOR_POSITION + == mConnection.getExpectedSelectionEnd()) { + // There are three possible reasons to send a key event: either the field has + // type TYPE_NULL, in which case the keyboard should send events, or we are + // running in backward compatibility mode, or we don't know the cursor position. + // Before Jelly bean, the keyboard would simulate a hardware keyboard event on + // pressing enter or delete. This is bad for many reasons (there are race + // conditions with commits) but some applications are relying on this behavior + // so we continue to support it for older apps, so we retain this behavior if + // the app has target SDK < JellyBean. + // As for the case where we don't know the cursor position, it can happen + // because of bugs in the framework. But the framework should know, so the next + // best thing is to leave it to whatever it thinks is best. + sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL); + int totalDeletedLength = 1; + if (mDeleteCount > Constants.DELETE_ACCELERATE_AT) { + // If this is an accelerated (i.e., double) deletion, then we need to + // consider unlearning here because we may have already reached + // the previous word, and will lose it after next deletion. + hasUnlearnedWordBeingDeleted |= unlearnWordBeingDeleted( + inputTransaction.mSettingsValues, currentKeyboardScriptId); + sendDownUpKeyEvent(KeyEvent.KEYCODE_DEL); + totalDeletedLength++; + } + StatsUtils.onBackspacePressed(totalDeletedLength); + } else { + final int codePointBeforeCursor = mConnection.getCodePointBeforeCursor(); + if (codePointBeforeCursor == Constants.NOT_A_CODE) { + // HACK for backward compatibility with broken apps that haven't realized + // yet that hardware keyboards are not the only way of inputting text. + // Nothing to delete before the cursor. We should not do anything, but many + // broken apps expect something to happen in this case so that they can + // catch it and have their broken interface react. If you need the keyboard + // to do this, you're doing it wrong -- please fix your app. + mConnection.deleteTextBeforeCursor(1); + // TODO: Add a new StatsUtils method onBackspaceWhenNoText() + return; + } + final int lengthToDelete = + Character.isSupplementaryCodePoint(codePointBeforeCursor) ? 2 : 1; + mConnection.deleteTextBeforeCursor(lengthToDelete); + int totalDeletedLength = lengthToDelete; + if (mDeleteCount > Constants.DELETE_ACCELERATE_AT) { + // If this is an accelerated (i.e., double) deletion, then we need to + // consider unlearning here because we may have already reached + // the previous word, and will lose it after next deletion. + hasUnlearnedWordBeingDeleted |= unlearnWordBeingDeleted( + inputTransaction.mSettingsValues, currentKeyboardScriptId); + final int codePointBeforeCursorToDeleteAgain = + mConnection.getCodePointBeforeCursor(); + if (codePointBeforeCursorToDeleteAgain != Constants.NOT_A_CODE) { + final int lengthToDeleteAgain = Character.isSupplementaryCodePoint( + codePointBeforeCursorToDeleteAgain) ? 2 : 1; + mConnection.deleteTextBeforeCursor(lengthToDeleteAgain); + totalDeletedLength += lengthToDeleteAgain; + } + } + StatsUtils.onBackspacePressed(totalDeletedLength); + } + } + if (!hasUnlearnedWordBeingDeleted) { + // Consider unlearning the word being deleted (if we have not done so already). + unlearnWordBeingDeleted( + inputTransaction.mSettingsValues, currentKeyboardScriptId); + } + if (mConnection.hasSlowInputConnection()) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + } else if (inputTransaction.mSettingsValues.isSuggestionsEnabledPerUserSettings() + && inputTransaction.mSettingsValues.mSpacingAndPunctuations + .mCurrentLanguageHasSpaces + && !mConnection.isCursorFollowedByWordCharacter( + inputTransaction.mSettingsValues.mSpacingAndPunctuations)) { + restartSuggestionsOnWordTouchedByCursor(inputTransaction.mSettingsValues, + false /* forStartInput */, currentKeyboardScriptId); + } + } + } + + String getWordAtCursor(final SettingsValues settingsValues, final int currentKeyboardScriptId) { + if (!mConnection.hasSelection() + && settingsValues.isSuggestionsEnabledPerUserSettings() + && settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces) { + final TextRange range = mConnection.getWordRangeAtCursor( + settingsValues.mSpacingAndPunctuations, + currentKeyboardScriptId); + if (range != null) { + return range.mWord.toString(); + } + } + return ""; + } + + boolean unlearnWordBeingDeleted( + final SettingsValues settingsValues, final int currentKeyboardScriptId) { + if (mConnection.hasSlowInputConnection()) { + // TODO: Refactor unlearning so that it does not incur any extra calls + // to the InputConnection. That way it can still be performed on a slow + // InputConnection. + Log.w(TAG, "Skipping unlearning due to slow InputConnection."); + return false; + } + // If we just started backspacing to delete a previous word (but have not + // entered the composing state yet), unlearn the word. + // TODO: Consider tracking whether or not this word was typed by the user. + if (!mConnection.isCursorFollowedByWordCharacter(settingsValues.mSpacingAndPunctuations)) { + final String wordBeingDeleted = getWordAtCursor( + settingsValues, currentKeyboardScriptId); + if (!TextUtils.isEmpty(wordBeingDeleted)) { + unlearnWord(wordBeingDeleted, settingsValues, Constants.EVENT_BACKSPACE); + return true; + } + } + return false; + } + + void unlearnWord(final String word, final SettingsValues settingsValues, final int eventType) { + final NgramContext ngramContext = mConnection.getNgramContextFromNthPreviousWord( + settingsValues.mSpacingAndPunctuations, 2); + final long timeStampInSeconds = TimeUnit.MILLISECONDS.toSeconds( + System.currentTimeMillis()); + mDictionaryFacilitator.unlearnFromUserHistory( + word, ngramContext, timeStampInSeconds, eventType); + } + + /** + * Handle a press on the language switch key (the "globe key") + */ + private void handleLanguageSwitchKey() { + mLatinIME.switchToNextSubtype(); + } + + /** + * Swap a space with a space-swapping punctuation sign. + * + * This method will check that there are two characters before the cursor and that the first + * one is a space before it does the actual swapping. + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + * @return true if the swap has been performed, false if it was prevented by preliminary checks. + */ + private boolean trySwapSwapperAndSpace(final Event event, + final InputTransaction inputTransaction) { + final int codePointBeforeCursor = mConnection.getCodePointBeforeCursor(); + if (Constants.CODE_SPACE != codePointBeforeCursor) { + return false; + } + mConnection.deleteTextBeforeCursor(1); + final String text = event.getTextToCommit() + " "; + mConnection.commitText(text, 1); + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + return true; + } + + /* + * Strip a trailing space if necessary and returns whether it's a swap weak space situation. + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + * @return whether we should swap the space instead of removing it. + */ + private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event, + final InputTransaction inputTransaction) { + final int codePoint = event.mCodePoint; + final boolean isFromSuggestionStrip = event.isSuggestionStripPress(); + if (Constants.CODE_ENTER == codePoint && + SpaceState.SWAP_PUNCTUATION == inputTransaction.mSpaceState) { + mConnection.removeTrailingSpace(); + return false; + } + if ((SpaceState.WEAK == inputTransaction.mSpaceState + || SpaceState.SWAP_PUNCTUATION == inputTransaction.mSpaceState) + && isFromSuggestionStrip) { + if (inputTransaction.mSettingsValues.isUsuallyPrecededBySpace(codePoint)) { + return false; + } + if (inputTransaction.mSettingsValues.isUsuallyFollowedBySpace(codePoint)) { + return true; + } + mConnection.removeTrailingSpace(); + } + return false; + } + + public void startDoubleSpacePeriodCountdown(final InputTransaction inputTransaction) { + mDoubleSpacePeriodCountdownStart = inputTransaction.mTimestamp; + } + + public void cancelDoubleSpacePeriodCountdown() { + mDoubleSpacePeriodCountdownStart = 0; + } + + public boolean isDoubleSpacePeriodCountdownActive(final InputTransaction inputTransaction) { + return inputTransaction.mTimestamp - mDoubleSpacePeriodCountdownStart + < inputTransaction.mSettingsValues.mDoubleSpacePeriodTimeout; + } + + /** + * Apply the double-space-to-period transformation if applicable. + * + * The double-space-to-period transformation means that we replace two spaces with a + * period-space sequence of characters. This typically happens when the user presses space + * twice in a row quickly. + * This method will check that the double-space-to-period is active in settings, that the + * two spaces have been input close enough together, that the typed character is a space + * and that the previous character allows for the transformation to take place. If all of + * these conditions are fulfilled, this method applies the transformation and returns true. + * Otherwise, it does nothing and returns false. + * + * @param event The event to handle. + * @param inputTransaction The transaction in progress. + * @return true if we applied the double-space-to-period transformation, false otherwise. + */ + private boolean tryPerformDoubleSpacePeriod(final Event event, + final InputTransaction inputTransaction) { + // Check the setting, the typed character and the countdown. If any of the conditions is + // not fulfilled, return false. + if (!inputTransaction.mSettingsValues.mUseDoubleSpacePeriod + || Constants.CODE_SPACE != event.mCodePoint + || !isDoubleSpacePeriodCountdownActive(inputTransaction)) { + return false; + } + // We only do this when we see one space and an accepted code point before the cursor. + // The code point may be a surrogate pair but the space may not, so we need 3 chars. + final CharSequence lastTwo = mConnection.getTextBeforeCursor(3, 0); + if (null == lastTwo) return false; + final int length = lastTwo.length(); + if (length < 2) return false; + if (lastTwo.charAt(length - 1) != Constants.CODE_SPACE) { + return false; + } + // We know there is a space in pos -1, and we have at least two chars. If we have only two + // chars, isSurrogatePairs can't return true as charAt(1) is a space, so this is fine. + final int firstCodePoint = + Character.isSurrogatePair(lastTwo.charAt(0), lastTwo.charAt(1)) ? + Character.codePointAt(lastTwo, length - 3) : lastTwo.charAt(length - 2); + if (canBeFollowedByDoubleSpacePeriod(firstCodePoint)) { + cancelDoubleSpacePeriodCountdown(); + mConnection.deleteTextBeforeCursor(1); + final String textToInsert = inputTransaction.mSettingsValues.mSpacingAndPunctuations + .mSentenceSeparatorAndSpace; + mConnection.commitText(textToInsert, 1); + inputTransaction.requireShiftUpdate(InputTransaction.SHIFT_UPDATE_NOW); + inputTransaction.setRequiresUpdateSuggestions(); + return true; + } + return false; + } + + /** + * Returns whether this code point can be followed by the double-space-to-period transformation. + * + * See #maybeDoubleSpaceToPeriod for details. + * Generally, most word characters can be followed by the double-space-to-period transformation, + * while most punctuation can't. Some punctuation however does allow for this to take place + * after them, like the closing parenthesis for example. + * + * @param codePoint the code point after which we may want to apply the transformation + * @return whether it's fine to apply the transformation after this code point. + */ + private static boolean canBeFollowedByDoubleSpacePeriod(final int codePoint) { + // TODO: This should probably be a denylist rather than a allowlist. + // TODO: This should probably be language-dependant... + return Character.isLetterOrDigit(codePoint) + || codePoint == Constants.CODE_SINGLE_QUOTE + || codePoint == Constants.CODE_DOUBLE_QUOTE + || codePoint == Constants.CODE_CLOSING_PARENTHESIS + || codePoint == Constants.CODE_CLOSING_SQUARE_BRACKET + || codePoint == Constants.CODE_CLOSING_CURLY_BRACKET + || codePoint == Constants.CODE_CLOSING_ANGLE_BRACKET + || codePoint == Constants.CODE_PLUS + || codePoint == Constants.CODE_PERCENT + || Character.getType(codePoint) == Character.OTHER_SYMBOL; + } + + /** + * Performs a recapitalization event. + * @param settingsValues The current settings values. + */ + private void performRecapitalization(final SettingsValues settingsValues) { + if (!mConnection.hasSelection() || !mRecapitalizeStatus.mIsEnabled()) { + return; // No selection or recapitalize is disabled for now + } + final int selectionStart = mConnection.getExpectedSelectionStart(); + final int selectionEnd = mConnection.getExpectedSelectionEnd(); + final int numCharsSelected = selectionEnd - selectionStart; + if (numCharsSelected > Constants.MAX_CHARACTERS_FOR_RECAPITALIZATION) { + // We bail out if we have too many characters for performance reasons. We don't want + // to suck possibly multiple-megabyte data. + return; + } + // If we have a recapitalize in progress, use it; otherwise, start a new one. + if (!mRecapitalizeStatus.isStarted() + || !mRecapitalizeStatus.isSetAt(selectionStart, selectionEnd)) { + final CharSequence selectedText = + mConnection.getSelectedText(0 /* flags, 0 for no styles */); + if (TextUtils.isEmpty(selectedText)) return; // Race condition with the input connection + mRecapitalizeStatus.start(selectionStart, selectionEnd, selectedText.toString(), + settingsValues.mLocale, + settingsValues.mSpacingAndPunctuations.mSortedWordSeparators); + // We trim leading and trailing whitespace. + mRecapitalizeStatus.trim(); + } + mConnection.finishComposingText(); + mRecapitalizeStatus.rotate(); + mConnection.setSelection(selectionEnd, selectionEnd); + mConnection.deleteTextBeforeCursor(numCharsSelected); + mConnection.commitText(mRecapitalizeStatus.getRecapitalizedString(), 0); + mConnection.setSelection(mRecapitalizeStatus.getNewCursorStart(), + mRecapitalizeStatus.getNewCursorEnd()); + } + + private void performAdditionToUserHistoryDictionary(final SettingsValues settingsValues, + final String suggestion, @Nonnull final NgramContext ngramContext) { + // If correction is not enabled, we don't add words to the user history dictionary. + // That's to avoid unintended additions in some sensitive fields, or fields that + // expect to receive non-words. + if (!settingsValues.mAutoCorrectionEnabledPerUserSettings) return; + if (mConnection.hasSlowInputConnection()) { + // Since we don't unlearn when the user backspaces on a slow InputConnection, + // turn off learning to guard against adding typos that the user later deletes. + Log.w(TAG, "Skipping learning due to slow InputConnection."); + return; + } + + if (TextUtils.isEmpty(suggestion)) return; + final boolean wasAutoCapitalized = + mWordComposer.wasAutoCapitalized() && !mWordComposer.isMostlyCaps(); + final int timeStampInSeconds = (int)TimeUnit.MILLISECONDS.toSeconds( + System.currentTimeMillis()); + mDictionaryFacilitator.addToUserHistory(suggestion, wasAutoCapitalized, + ngramContext, timeStampInSeconds, settingsValues.mBlockPotentiallyOffensive); + } + + public void performUpdateSuggestionStripSync(final SettingsValues settingsValues, + final int inputStyle) { + long startTimeMillis = 0; + if (DebugFlags.DEBUG_ENABLED) { + startTimeMillis = System.currentTimeMillis(); + Log.d(TAG, "performUpdateSuggestionStripSync()"); + } + // Check if we have a suggestion engine attached. + if (!settingsValues.needsToLookupSuggestions()) { + if (mWordComposer.isComposingWord()) { + Log.w(TAG, "Called updateSuggestionsOrPredictions but suggestions were not " + + "requested!"); + } + // Clear the suggestions strip. + mSuggestionStripViewAccessor.showSuggestionStrip(SuggestedWords.getEmptyInstance()); + return; + } + + if (!mWordComposer.isComposingWord() && !settingsValues.mBigramPredictionEnabled) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + return; + } + + final AsyncResultHolder<SuggestedWords> holder = new AsyncResultHolder<>("Suggest"); + mInputLogicHandler.getSuggestedWords(inputStyle, SuggestedWords.NOT_A_SEQUENCE_NUMBER, + new OnGetSuggestedWordsCallback() { + @Override + public void onGetSuggestedWords(final SuggestedWords suggestedWords) { + final String typedWordString = mWordComposer.getTypedWord(); + final SuggestedWordInfo typedWordInfo = new SuggestedWordInfo( + typedWordString, "" /* prevWordsContext */, + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX /* indexOfTouchPointOfSecondWord */, + SuggestedWordInfo.NOT_A_CONFIDENCE); + // Show new suggestions if we have at least one. Otherwise keep the old + // suggestions with the new typed word. Exception: if the length of the + // typed word is <= 1 (after a deletion typically) we clear old suggestions. + if (suggestedWords.size() > 1 || typedWordString.length() <= 1) { + holder.set(suggestedWords); + } else { + holder.set(retrieveOlderSuggestions(typedWordInfo, mSuggestedWords)); + } + } + } + ); + + // This line may cause the current thread to wait. + final SuggestedWords suggestedWords = holder.get(null, + Constants.GET_SUGGESTED_WORDS_TIMEOUT); + if (suggestedWords != null) { + mSuggestionStripViewAccessor.showSuggestionStrip(suggestedWords); + } + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "performUpdateSuggestionStripSync() : " + runTimeMillis + " ms to finish"); + } + } + + /** + * Check if the cursor is touching a word. If so, restart suggestions on this word, else + * do nothing. + * + * @param settingsValues the current values of the settings. + * @param forStartInput whether we're doing this in answer to starting the input (as opposed + * to a cursor move, for example). In ICS, there is a platform bug that we need to work + * around only when we come here at input start time. + */ + public void restartSuggestionsOnWordTouchedByCursor(final SettingsValues settingsValues, + final boolean forStartInput, + // TODO: remove this argument, put it into settingsValues + final int currentKeyboardScriptId) { + // HACK: We may want to special-case some apps that exhibit bad behavior in case of + // recorrection. This is a temporary, stopgap measure that will be removed later. + // TODO: remove this. + if (settingsValues.isBrokenByRecorrection() + // Recorrection is not supported in languages without spaces because we don't know + // how to segment them yet. + || !settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + // If no suggestions are requested, don't try restarting suggestions. + || !settingsValues.needsToLookupSuggestions() + // If we are currently in a batch input, we must not resume suggestions, or the result + // of the batch input will replace the new composition. This may happen in the corner case + // that the app moves the cursor on its own accord during a batch input. + || mInputLogicHandler.isInBatchInput() + // If the cursor is not touching a word, or if there is a selection, return right away. + || mConnection.hasSelection() + // If we don't know the cursor location, return. + || mConnection.getExpectedSelectionStart() < 0) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + return; + } + final int expectedCursorPosition = mConnection.getExpectedSelectionStart(); + if (!mConnection.isCursorTouchingWord(settingsValues.mSpacingAndPunctuations, + true /* checkTextAfter */)) { + // Show predictions. + mWordComposer.setCapitalizedModeAtStartComposingTime(WordComposer.CAPS_MODE_OFF); + mLatinIME.mHandler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_RECORRECTION); + return; + } + final TextRange range = mConnection.getWordRangeAtCursor( + settingsValues.mSpacingAndPunctuations, currentKeyboardScriptId); + if (null == range) return; // Happens if we don't have an input connection at all + if (range.length() <= 0) { + // Race condition, or touching a word in a non-supported script. + mLatinIME.setNeutralSuggestionStrip(); + return; + } + // If for some strange reason (editor bug or so) we measure the text before the cursor as + // longer than what the entire text is supposed to be, the safe thing to do is bail out. + if (range.mHasUrlSpans) return; // If there are links, we don't resume suggestions. Making + // edits to a linkified text through batch commands would ruin the URL spans, and unless + // we take very complicated steps to preserve the whole link, we can't do things right so + // we just do not resume because it's safer. + final int numberOfCharsInWordBeforeCursor = range.getNumberOfCharsInWordBeforeCursor(); + if (numberOfCharsInWordBeforeCursor > expectedCursorPosition) return; + final ArrayList<SuggestedWordInfo> suggestions = new ArrayList<>(); + final String typedWordString = range.mWord.toString(); + final SuggestedWordInfo typedWordInfo = new SuggestedWordInfo(typedWordString, + "" /* prevWordsContext */, SuggestedWords.MAX_SUGGESTIONS + 1, + SuggestedWordInfo.KIND_TYPED, Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX /* indexOfTouchPointOfSecondWord */, + SuggestedWordInfo.NOT_A_CONFIDENCE /* autoCommitFirstWordConfidence */); + suggestions.add(typedWordInfo); + if (!isResumableWord(settingsValues, typedWordString)) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + return; + } + int i = 0; + for (final SuggestionSpan span : range.getSuggestionSpansAtWord()) { + for (final String s : span.getSuggestions()) { + ++i; + if (!TextUtils.equals(s, typedWordString)) { + suggestions.add(new SuggestedWordInfo(s, + "" /* prevWordsContext */, SuggestedWords.MAX_SUGGESTIONS - i, + SuggestedWordInfo.KIND_RESUMED, Dictionary.DICTIONARY_RESUMED, + SuggestedWordInfo.NOT_AN_INDEX /* indexOfTouchPointOfSecondWord */, + SuggestedWordInfo.NOT_A_CONFIDENCE + /* autoCommitFirstWordConfidence */)); + } + } + } + final int[] codePoints = StringUtils.toCodePointArray(typedWordString); + mWordComposer.setComposingWord(codePoints, + mLatinIME.getCoordinatesForCurrentKeyboard(codePoints)); + mWordComposer.setCursorPositionWithinWord( + typedWordString.codePointCount(0, numberOfCharsInWordBeforeCursor)); + if (forStartInput) { + mConnection.maybeMoveTheCursorAroundAndRestoreToWorkaroundABug(); + } + mConnection.setComposingRegion(expectedCursorPosition - numberOfCharsInWordBeforeCursor, + expectedCursorPosition + range.getNumberOfCharsInWordAfterCursor()); + if (suggestions.size() <= 1) { + // If there weren't any suggestion spans on this word, suggestions#size() will be 1 + // if shouldIncludeResumedWordInSuggestions is true, 0 otherwise. In this case, we + // have no useful suggestions, so we will try to compute some for it instead. + mInputLogicHandler.getSuggestedWords(Suggest.SESSION_ID_TYPING, + SuggestedWords.NOT_A_SEQUENCE_NUMBER, new OnGetSuggestedWordsCallback() { + @Override + public void onGetSuggestedWords(final SuggestedWords suggestedWords) { + doShowSuggestionsAndClearAutoCorrectionIndicator(suggestedWords); + }}); + } else { + // We found suggestion spans in the word. We'll create the SuggestedWords out of + // them, and make willAutoCorrect false. We make typedWordValid false, because the + // color of the word in the suggestion strip changes according to this parameter, + // and false gives the correct color. + final SuggestedWords suggestedWords = new SuggestedWords(suggestions, + null /* rawSuggestions */, typedWordInfo, false /* typedWordValid */, + false /* willAutoCorrect */, false /* isObsoleteSuggestions */, + SuggestedWords.INPUT_STYLE_RECORRECTION, SuggestedWords.NOT_A_SEQUENCE_NUMBER); + doShowSuggestionsAndClearAutoCorrectionIndicator(suggestedWords); + } + } + + void doShowSuggestionsAndClearAutoCorrectionIndicator(final SuggestedWords suggestedWords) { + mIsAutoCorrectionIndicatorOn = false; + mLatinIME.mHandler.showSuggestionStrip(suggestedWords); + } + + /** + * Reverts a previous commit with auto-correction. + * + * This is triggered upon pressing backspace just after a commit with auto-correction. + * + * @param inputTransaction The transaction in progress. + * @param settingsValues the current values of the settings. + */ + private void revertCommit(final InputTransaction inputTransaction, + final SettingsValues settingsValues) { + final CharSequence originallyTypedWord = mLastComposedWord.mTypedWord; + final String originallyTypedWordString = + originallyTypedWord != null ? originallyTypedWord.toString() : ""; + final CharSequence committedWord = mLastComposedWord.mCommittedWord; + final String committedWordString = committedWord.toString(); + final int cancelLength = committedWord.length(); + final String separatorString = mLastComposedWord.mSeparatorString; + // If our separator is a space, we won't actually commit it, + // but set the space state to PHANTOM so that a space will be inserted + // on the next keypress + final boolean usePhantomSpace = separatorString.equals(Constants.STRING_SPACE); + // We want java chars, not codepoints for the following. + final int separatorLength = separatorString.length(); + // TODO: should we check our saved separator against the actual contents of the text view? + final int deleteLength = cancelLength + separatorLength; + if (DebugFlags.DEBUG_ENABLED) { + if (mWordComposer.isComposingWord()) { + throw new RuntimeException("revertCommit, but we are composing a word"); + } + final CharSequence wordBeforeCursor = + mConnection.getTextBeforeCursor(deleteLength, 0).subSequence(0, cancelLength); + if (!TextUtils.equals(committedWord, wordBeforeCursor)) { + throw new RuntimeException("revertCommit check failed: we thought we were " + + "reverting \"" + committedWord + + "\", but before the cursor we found \"" + wordBeforeCursor + "\""); + } + } + mConnection.deleteTextBeforeCursor(deleteLength); + if (!TextUtils.isEmpty(committedWord)) { + unlearnWord(committedWordString, inputTransaction.mSettingsValues, + Constants.EVENT_REVERT); + } + final String stringToCommit = originallyTypedWord + + (usePhantomSpace ? "" : separatorString); + final SpannableString textToCommit = new SpannableString(stringToCommit); + if (committedWord instanceof SpannableString) { + final SpannableString committedWordWithSuggestionSpans = (SpannableString)committedWord; + final Object[] spans = committedWordWithSuggestionSpans.getSpans(0, + committedWord.length(), Object.class); + final int lastCharIndex = textToCommit.length() - 1; + // We will collect all suggestions in the following array. + final ArrayList<String> suggestions = new ArrayList<>(); + // First, add the committed word to the list of suggestions. + suggestions.add(committedWordString); + for (final Object span : spans) { + // If this is a suggestion span, we check that the word is not the committed word. + // That should mostly be the case. + // Given this, we add it to the list of suggestions, otherwise we discard it. + if (span instanceof SuggestionSpan) { + final SuggestionSpan suggestionSpan = (SuggestionSpan)span; + for (final String suggestion : suggestionSpan.getSuggestions()) { + if (!suggestion.equals(committedWordString)) { + suggestions.add(suggestion); + } + } + } else { + // If this is not a suggestion span, we just add it as is. + textToCommit.setSpan(span, 0 /* start */, lastCharIndex /* end */, + committedWordWithSuggestionSpans.getSpanFlags(span)); + } + } + // Add the suggestion list to the list of suggestions. + textToCommit.setSpan(new SuggestionSpan(mLatinIME /* context */, + inputTransaction.mSettingsValues.mLocale, + suggestions.toArray(new String[suggestions.size()]), 0 /* flags */, + null /* notificationTargetClass */), + 0 /* start */, lastCharIndex /* end */, 0 /* flags */); + } + + if (inputTransaction.mSettingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces) { + mConnection.commitText(textToCommit, 1); + if (usePhantomSpace) { + mSpaceState = SpaceState.PHANTOM; + } + } else { + // For languages without spaces, we revert the typed string but the cursor is flush + // with the typed word, so we need to resume suggestions right away. + final int[] codePoints = StringUtils.toCodePointArray(stringToCommit); + mWordComposer.setComposingWord(codePoints, + mLatinIME.getCoordinatesForCurrentKeyboard(codePoints)); + setComposingTextInternal(textToCommit, 1); + } + // Don't restart suggestion yet. We'll restart if the user deletes the separator. + mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD; + + // We have a separator between the word and the cursor: we should show predictions. + inputTransaction.setRequiresUpdateSuggestions(); + } + + /** + * Factor in auto-caps and manual caps and compute the current caps mode. + * @param settingsValues the current settings values. + * @param keyboardShiftMode the current shift mode of the keyboard. See + * KeyboardSwitcher#getKeyboardShiftMode() for possible values. + * @return the actual caps mode the keyboard is in right now. + */ + private int getActualCapsMode(final SettingsValues settingsValues, + final int keyboardShiftMode) { + if (keyboardShiftMode != WordComposer.CAPS_MODE_AUTO_SHIFTED) { + return keyboardShiftMode; + } + final int auto = getCurrentAutoCapsState(settingsValues); + if (0 != (auto & TextUtils.CAP_MODE_CHARACTERS)) { + return WordComposer.CAPS_MODE_AUTO_SHIFT_LOCKED; + } + if (0 != auto) { + return WordComposer.CAPS_MODE_AUTO_SHIFTED; + } + return WordComposer.CAPS_MODE_OFF; + } + + /** + * Gets the current auto-caps state, factoring in the space state. + * + * This method tries its best to do this in the most efficient possible manner. It avoids + * getting text from the editor if possible at all. + * This is called from the KeyboardSwitcher (through a trampoline in LatinIME) because it + * needs to know auto caps state to display the right layout. + * + * @param settingsValues the relevant settings values + * @return a caps mode from TextUtils.CAP_MODE_* or Constants.TextUtils.CAP_MODE_OFF. + */ + public int getCurrentAutoCapsState(final SettingsValues settingsValues) { + if (!settingsValues.mAutoCap) return Constants.TextUtils.CAP_MODE_OFF; + + final EditorInfo ei = getCurrentInputEditorInfo(); + if (ei == null) return Constants.TextUtils.CAP_MODE_OFF; + final int inputType = ei.inputType; + // Warning: this depends on mSpaceState, which may not be the most current value. If + // mSpaceState gets updated later, whoever called this may need to be told about it. + return mConnection.getCursorCapsMode(inputType, settingsValues.mSpacingAndPunctuations, + SpaceState.PHANTOM == mSpaceState); + } + + public int getCurrentRecapitalizeState() { + if (!mRecapitalizeStatus.isStarted() + || !mRecapitalizeStatus.isSetAt(mConnection.getExpectedSelectionStart(), + mConnection.getExpectedSelectionEnd())) { + // Not recapitalizing at the moment + return RecapitalizeStatus.NOT_A_RECAPITALIZE_MODE; + } + return mRecapitalizeStatus.getCurrentMode(); + } + + /** + * @return the editor info for the current editor + */ + private EditorInfo getCurrentInputEditorInfo() { + return mLatinIME.getCurrentInputEditorInfo(); + } + + /** + * Get n-gram context from the nth previous word before the cursor as context + * for the suggestion process. + * @param spacingAndPunctuations the current spacing and punctuations settings. + * @param nthPreviousWord reverse index of the word to get (1-indexed) + * @return the information of previous words + */ + public NgramContext getNgramContextFromNthPreviousWordForSuggestion( + final SpacingAndPunctuations spacingAndPunctuations, final int nthPreviousWord) { + if (spacingAndPunctuations.mCurrentLanguageHasSpaces) { + // If we are typing in a language with spaces we can just look up the previous + // word information from textview. + return mConnection.getNgramContextFromNthPreviousWord( + spacingAndPunctuations, nthPreviousWord); + } + if (LastComposedWord.NOT_A_COMPOSED_WORD == mLastComposedWord) { + return NgramContext.BEGINNING_OF_SENTENCE; + } + return new NgramContext(new NgramContext.WordInfo( + mLastComposedWord.mCommittedWord.toString())); + } + + /** + * Tests the passed word for resumability. + * + * We can resume suggestions on words whose first code point is a word code point (with some + * nuances: check the code for details). + * + * @param settings the current values of the settings. + * @param word the word to evaluate. + * @return whether it's fine to resume suggestions on this word. + */ + private static boolean isResumableWord(final SettingsValues settings, final String word) { + final int firstCodePoint = word.codePointAt(0); + return settings.isWordCodePoint(firstCodePoint) + && Constants.CODE_SINGLE_QUOTE != firstCodePoint + && Constants.CODE_DASH != firstCodePoint; + } + + /** + * @param actionId the action to perform + */ + private void performEditorAction(final int actionId) { + mConnection.performEditorAction(actionId); + } + + /** + * Perform the processing specific to inputting TLDs. + * + * Some keys input a TLD (specifically, the ".com" key) and this warrants some specific + * processing. First, if this is a TLD, we ignore PHANTOM spaces -- this is done by type + * of character in onCodeInput, but since this gets inputted as a whole string we need to + * do it here specifically. Then, if the last character before the cursor is a period, then + * we cut the dot at the start of ".com". This is because humans tend to type "www.google." + * and then press the ".com" key and instinctively don't expect to get "www.google..com". + * + * @param text the raw text supplied to onTextInput + * @return the text to actually send to the editor + */ + private String performSpecificTldProcessingOnTextInput(final String text) { + if (text.length() <= 1 || text.charAt(0) != Constants.CODE_PERIOD + || !Character.isLetter(text.charAt(1))) { + // Not a tld: do nothing. + return text; + } + // We have a TLD (or something that looks like this): make sure we don't add + // a space even if currently in phantom mode. + mSpaceState = SpaceState.NONE; + final int codePointBeforeCursor = mConnection.getCodePointBeforeCursor(); + // If no code point, #getCodePointBeforeCursor returns NOT_A_CODE_POINT. + if (Constants.CODE_PERIOD == codePointBeforeCursor) { + return text.substring(1); + } + return text; + } + + /** + * Handle a press on the settings key. + */ + private void onSettingsKeyPressed() { + mLatinIME.displaySettingsDialog(); + } + + /** + * Resets the whole input state to the starting state. + * + * This will clear the composing word, reset the last composed word, clear the suggestion + * strip and tell the input connection about it so that it can refresh its caches. + * + * @param newSelStart the new selection start, in java characters. + * @param newSelEnd the new selection end, in java characters. + * @param clearSuggestionStrip whether this method should clear the suggestion strip. + */ + // TODO: how is this different from startInput ?! + private void resetEntireInputState(final int newSelStart, final int newSelEnd, + final boolean clearSuggestionStrip) { + final boolean shouldFinishComposition = mWordComposer.isComposingWord(); + resetComposingState(true /* alsoResetLastComposedWord */); + if (clearSuggestionStrip) { + mSuggestionStripViewAccessor.setNeutralSuggestionStrip(); + } + mConnection.resetCachesUponCursorMoveAndReturnSuccess(newSelStart, newSelEnd, + shouldFinishComposition); + } + + /** + * Resets only the composing state. + * + * Compare #resetEntireInputState, which also clears the suggestion strip and resets the + * input connection caches. This only deals with the composing state. + * + * @param alsoResetLastComposedWord whether to also reset the last composed word. + */ + private void resetComposingState(final boolean alsoResetLastComposedWord) { + mWordComposer.reset(); + if (alsoResetLastComposedWord) { + mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD; + } + } + + /** + * Make a {@link SuggestedWords} object containing a typed word + * and obsolete suggestions. + * See {@link SuggestedWords#getTypedWordAndPreviousSuggestions( + * SuggestedWordInfo, SuggestedWords)}. + * @param typedWordInfo The typed word as a SuggestedWordInfo. + * @param previousSuggestedWords The previously suggested words. + * @return Obsolete suggestions with the newly typed word. + */ + static SuggestedWords retrieveOlderSuggestions(final SuggestedWordInfo typedWordInfo, + final SuggestedWords previousSuggestedWords) { + final SuggestedWords oldSuggestedWords = previousSuggestedWords.isPunctuationSuggestions() + ? SuggestedWords.getEmptyInstance() : previousSuggestedWords; + final ArrayList<SuggestedWords.SuggestedWordInfo> typedWordAndPreviousSuggestions = + SuggestedWords.getTypedWordAndPreviousSuggestions(typedWordInfo, oldSuggestedWords); + return new SuggestedWords(typedWordAndPreviousSuggestions, null /* rawSuggestions */, + typedWordInfo, false /* typedWordValid */, false /* hasAutoCorrectionCandidate */, + true /* isObsoleteSuggestions */, oldSuggestedWords.mInputStyle, + SuggestedWords.NOT_A_SEQUENCE_NUMBER); + } + + /** + * @return the {@link Locale} of the {@link #mDictionaryFacilitator} if available. Otherwise + * {@link Locale#ROOT}. + */ + @Nonnull + private Locale getDictionaryFacilitatorLocale() { + return mDictionaryFacilitator != null ? mDictionaryFacilitator.getLocale() : Locale.ROOT; + } + + /** + * Gets a chunk of text with or the auto-correction indicator underline span as appropriate. + * + * This method looks at the old state of the auto-correction indicator to put or not put + * the underline span as appropriate. It is important to note that this does not correspond + * exactly to whether this word will be auto-corrected to or not: what's important here is + * to keep the same indication as before. + * When we add a new code point to a composing word, we don't know yet if we are going to + * auto-correct it until the suggestions are computed. But in the mean time, we still need + * to display the character and to extend the previous underline. To avoid any flickering, + * the underline should keep the same color it used to have, even if that's not ultimately + * the correct color for this new word. When the suggestions are finished evaluating, we + * will call this method again to fix the color of the underline. + * + * @param text the text on which to maybe apply the span. + * @return the same text, with the auto-correction underline span if that's appropriate. + */ + // TODO: Shouldn't this go in some *Utils class instead? + private CharSequence getTextWithUnderline(final String text) { + // TODO: Locale should be determined based on context and the text given. + return mIsAutoCorrectionIndicatorOn + ? SuggestionSpanUtils.getTextWithAutoCorrectionIndicatorUnderline( + mLatinIME, text, getDictionaryFacilitatorLocale()) + : text; + } + + /** + * Sends a DOWN key event followed by an UP key event to the editor. + * + * If possible at all, avoid using this method. It causes all sorts of race conditions with + * the text view because it goes through a different, asynchronous binder. Also, batch edits + * are ignored for key events. Use the normal software input methods instead. + * + * @param keyCode the key code to send inside the key event. + */ + private void sendDownUpKeyEvent(final int keyCode) { + final long eventTime = SystemClock.uptimeMillis(); + mConnection.sendKeyEvent(new KeyEvent(eventTime, eventTime, + KeyEvent.ACTION_DOWN, keyCode, 0, 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, + KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE)); + mConnection.sendKeyEvent(new KeyEvent(SystemClock.uptimeMillis(), eventTime, + KeyEvent.ACTION_UP, keyCode, 0, 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, + KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE)); + } + + /** + * Sends a code point to the editor, using the most appropriate method. + * + * Normally we send code points with commitText, but there are some cases (where backward + * compatibility is a concern for example) where we want to use deprecated methods. + * + * @param settingsValues the current values of the settings. + * @param codePoint the code point to send. + */ + // TODO: replace these two parameters with an InputTransaction + private void sendKeyCodePoint(final SettingsValues settingsValues, final int codePoint) { + // TODO: Remove this special handling of digit letters. + // For backward compatibility. See {@link InputMethodService#sendKeyChar(char)}. + if (codePoint >= '0' && codePoint <= '9') { + sendDownUpKeyEvent(codePoint - '0' + KeyEvent.KEYCODE_0); + return; + } + + // TODO: we should do this also when the editor has TYPE_NULL + if (Constants.CODE_ENTER == codePoint && settingsValues.isBeforeJellyBean()) { + // Backward compatibility mode. Before Jelly bean, the keyboard would simulate + // a hardware keyboard event on pressing enter or delete. This is bad for many + // reasons (there are race conditions with commits) but some applications are + // relying on this behavior so we continue to support it for older apps. + sendDownUpKeyEvent(KeyEvent.KEYCODE_ENTER); + } else { + mConnection.commitText(StringUtils.newSingleCodePointString(codePoint), 1); + } + } + + /** + * Insert an automatic space, if the options allow it. + * + * This checks the options and the text before the cursor are appropriate before inserting + * an automatic space. + * + * @param settingsValues the current values of the settings. + */ + private void insertAutomaticSpaceIfOptionsAndTextAllow(final SettingsValues settingsValues) { + if (settingsValues.shouldInsertSpacesAutomatically() + && settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + && !mConnection.textBeforeCursorLooksLikeURL()) { + sendKeyCodePoint(settingsValues, Constants.CODE_SPACE); + } + } + + /** + * Do the final processing after a batch input has ended. This commits the word to the editor. + * @param settingsValues the current values of the settings. + * @param suggestedWords suggestedWords to use. + */ + public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, + final SuggestedWords suggestedWords, final KeyboardSwitcher keyboardSwitcher) { + final String batchInputText = suggestedWords.isEmpty() ? null : suggestedWords.getWord(0); + if (TextUtils.isEmpty(batchInputText)) { + return; + } + mConnection.beginBatchEdit(); + if (SpaceState.PHANTOM == mSpaceState) { + insertAutomaticSpaceIfOptionsAndTextAllow(settingsValues); + } + mWordComposer.setBatchInputWord(batchInputText); + setComposingTextInternal(batchInputText, 1); + mConnection.endBatchEdit(); + // Space state must be updated before calling updateShiftState + mSpaceState = SpaceState.PHANTOM; + keyboardSwitcher.requestUpdatingShiftState(getCurrentAutoCapsState(settingsValues), + getCurrentRecapitalizeState()); + } + + /** + * Commit the typed string to the editor. + * + * This is typically called when we should commit the currently composing word without applying + * auto-correction to it. Typically, we come here upon pressing a separator when the keyboard + * is configured to not do auto-correction at all (because of the settings or the properties of + * the editor). In this case, `separatorString' is set to the separator that was pressed. + * We also come here in a variety of cases with external user action. For example, when the + * cursor is moved while there is a composition, or when the keyboard is closed, or when the + * user presses the Send button for an SMS, we don't auto-correct as that would be unexpected. + * In this case, `separatorString' is set to NOT_A_SEPARATOR. + * + * @param settingsValues the current values of the settings. + * @param separatorString the separator that's causing the commit, or NOT_A_SEPARATOR if none. + */ + public void commitTyped(final SettingsValues settingsValues, final String separatorString) { + if (!mWordComposer.isComposingWord()) return; + final String typedWord = mWordComposer.getTypedWord(); + if (typedWord.length() > 0) { + final boolean isBatchMode = mWordComposer.isBatchMode(); + commitChosenWord(settingsValues, typedWord, + LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, separatorString); + StatsUtils.onWordCommitUserTyped(typedWord, isBatchMode); + } + } + + /** + * Commit the current auto-correction. + * + * This will commit the best guess of the keyboard regarding what the user meant by typing + * the currently composing word. The IME computes suggestions and assigns a confidence score + * to each of them; when it's confident enough in one suggestion, it replaces the typed string + * by this suggestion at commit time. When it's not confident enough, or when it has no + * suggestions, or when the settings or environment does not allow for auto-correction, then + * this method just commits the typed string. + * Note that if suggestions are currently being computed in the background, this method will + * block until the computation returns. This is necessary for consistency (it would be very + * strange if pressing space would commit a different word depending on how fast you press). + * + * @param settingsValues the current value of the settings. + * @param separator the separator that's causing the commit to happen. + */ + private void commitCurrentAutoCorrection(final SettingsValues settingsValues, + final String separator, final LatinIME.UIHandler handler) { + // Complete any pending suggestions query first + if (handler.hasPendingUpdateSuggestions()) { + handler.cancelUpdateSuggestionStrip(); + // To know the input style here, we should retrieve the in-flight "update suggestions" + // message and read its arg1 member here. However, the Handler class does not let + // us retrieve this message, so we can't do that. But in fact, we notice that + // we only ever come here when the input style was typing. In the case of batch + // input, we update the suggestions synchronously when the tail batch comes. Likewise + // for application-specified completions. As for recorrections, we never auto-correct, + // so we don't come here either. Hence, the input style is necessarily + // INPUT_STYLE_TYPING. + performUpdateSuggestionStripSync(settingsValues, SuggestedWords.INPUT_STYLE_TYPING); + } + final SuggestedWordInfo autoCorrectionOrNull = mWordComposer.getAutoCorrectionOrNull(); + final String typedWord = mWordComposer.getTypedWord(); + final String stringToCommit = (autoCorrectionOrNull != null) + ? autoCorrectionOrNull.mWord : typedWord; + if (stringToCommit != null) { + if (TextUtils.isEmpty(typedWord)) { + throw new RuntimeException("We have an auto-correction but the typed word " + + "is empty? Impossible! I must commit suicide."); + } + final boolean isBatchMode = mWordComposer.isBatchMode(); + commitChosenWord(settingsValues, stringToCommit, + LastComposedWord.COMMIT_TYPE_DECIDED_WORD, separator); + if (!typedWord.equals(stringToCommit)) { + // This will make the correction flash for a short while as a visual clue + // to the user that auto-correction happened. It has no other effect; in particular + // note that this won't affect the text inside the text field AT ALL: it only makes + // the segment of text starting at the supplied index and running for the length + // of the auto-correction flash. At this moment, the "typedWord" argument is + // ignored by TextView. + mConnection.commitCorrection(new CorrectionInfo( + mConnection.getExpectedSelectionEnd() - stringToCommit.length(), + typedWord, stringToCommit)); + String prevWordsContext = (autoCorrectionOrNull != null) + ? autoCorrectionOrNull.mPrevWordsContext + : ""; + StatsUtils.onAutoCorrection(typedWord, stringToCommit, isBatchMode, + mDictionaryFacilitator, prevWordsContext); + StatsUtils.onWordCommitAutoCorrect(stringToCommit, isBatchMode); + } else { + StatsUtils.onWordCommitUserTyped(stringToCommit, isBatchMode); + } + } + } + + /** + * Commits the chosen word to the text field and saves it for later retrieval. + * + * @param settingsValues the current values of the settings. + * @param chosenWord the word we want to commit. + * @param commitType the type of the commit, as one of LastComposedWord.COMMIT_TYPE_* + * @param separatorString the separator that's causing the commit, or NOT_A_SEPARATOR if none. + */ + private void commitChosenWord(final SettingsValues settingsValues, final String chosenWord, + final int commitType, final String separatorString) { + long startTimeMillis = 0; + if (DebugFlags.DEBUG_ENABLED) { + startTimeMillis = System.currentTimeMillis(); + Log.d(TAG, "commitChosenWord() : [" + chosenWord + "]"); + } + final SuggestedWords suggestedWords = mSuggestedWords; + // TODO: Locale should be determined based on context and the text given. + final Locale locale = getDictionaryFacilitatorLocale(); + final CharSequence chosenWordWithSuggestions = chosenWord; + // b/21926256 + // SuggestionSpanUtils.getTextWithSuggestionSpan(mLatinIME, chosenWord, + // suggestedWords, locale); + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " + + "SuggestionSpanUtils.getTextWithSuggestionSpan()"); + startTimeMillis = System.currentTimeMillis(); + } + // When we are composing word, get n-gram context from the 2nd previous word because the + // 1st previous word is the word to be committed. Otherwise get n-gram context from the 1st + // previous word. + final NgramContext ngramContext = mConnection.getNgramContextFromNthPreviousWord( + settingsValues.mSpacingAndPunctuations, mWordComposer.isComposingWord() ? 2 : 1); + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " + + "Connection.getNgramContextFromNthPreviousWord()"); + Log.d(TAG, "commitChosenWord() : NgramContext = " + ngramContext); + startTimeMillis = System.currentTimeMillis(); + } + mConnection.commitText(chosenWordWithSuggestions, 1); + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " + + "Connection.commitText"); + startTimeMillis = System.currentTimeMillis(); + } + // Add the word to the user history dictionary + performAdditionToUserHistoryDictionary(settingsValues, chosenWord, ngramContext); + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " + + "performAdditionToUserHistoryDictionary()"); + startTimeMillis = System.currentTimeMillis(); + } + // TODO: figure out here if this is an auto-correct or if the best word is actually + // what user typed. Note: currently this is done much later in + // LastComposedWord#didCommitTypedWord by string equality of the remembered + // strings. + mLastComposedWord = mWordComposer.commitWord(commitType, + chosenWordWithSuggestions, separatorString, ngramContext); + if (DebugFlags.DEBUG_ENABLED) { + long runTimeMillis = System.currentTimeMillis() - startTimeMillis; + Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run " + + "WordComposer.commitWord()"); + startTimeMillis = System.currentTimeMillis(); + } + } + + /** + * Retry resetting caches in the rich input connection. + * + * When the editor can't be accessed we can't reset the caches, so we schedule a retry. + * This method handles the retry, and re-schedules a new retry if we still can't access. + * We only retry up to 5 times before giving up. + * + * @param tryResumeSuggestions Whether we should resume suggestions or not. + * @param remainingTries How many times we may try again before giving up. + * @return whether true if the caches were successfully reset, false otherwise. + */ + public boolean retryResetCachesAndReturnSuccess(final boolean tryResumeSuggestions, + final int remainingTries, final LatinIME.UIHandler handler) { + final boolean shouldFinishComposition = mConnection.hasSelection() + || !mConnection.isCursorPositionKnown(); + if (!mConnection.resetCachesUponCursorMoveAndReturnSuccess( + mConnection.getExpectedSelectionStart(), mConnection.getExpectedSelectionEnd(), + shouldFinishComposition)) { + if (0 < remainingTries) { + handler.postResetCaches(tryResumeSuggestions, remainingTries - 1); + return false; + } + // If remainingTries is 0, we should stop waiting for new tries, however we'll still + // return true as we need to perform other tasks (for example, loading the keyboard). + } + mConnection.tryFixLyingCursorPosition(); + if (tryResumeSuggestions) { + handler.postResumeSuggestions(true /* shouldDelay */); + } + return true; + } + + public void getSuggestedWords(final SettingsValues settingsValues, + final Keyboard keyboard, final int keyboardShiftMode, final int inputStyle, + final int sequenceNumber, final OnGetSuggestedWordsCallback callback) { + mWordComposer.adviseCapitalizedModeBeforeFetchingSuggestions( + getActualCapsMode(settingsValues, keyboardShiftMode)); + mSuggest.getSuggestedWords(mWordComposer, + getNgramContextFromNthPreviousWordForSuggestion( + settingsValues.mSpacingAndPunctuations, + // Get the word on which we should search the bigrams. If we are composing + // a word, it's whatever is *before* the half-committed word in the buffer, + // hence 2; if we aren't, we should just skip whitespace if any, so 1. + mWordComposer.isComposingWord() ? 2 : 1), + keyboard, + new SettingsValuesForSuggestion(settingsValues.mBlockPotentiallyOffensive), + settingsValues.mAutoCorrectionEnabledPerUserSettings, + inputStyle, sequenceNumber, callback); + } + + /** + * Used as an injection point for each call of + * {@link RichInputConnection#setComposingText(CharSequence, int)}. + * + * <p>Currently using this method is optional and you can still directly call + * {@link RichInputConnection#setComposingText(CharSequence, int)}, but it is recommended to + * use this method whenever possible.<p> + * <p>TODO: Should we move this mechanism to {@link RichInputConnection}?</p> + * + * @param newComposingText the composing text to be set + * @param newCursorPosition the new cursor position + */ + private void setComposingTextInternal(final CharSequence newComposingText, + final int newCursorPosition) { + setComposingTextInternalWithBackgroundColor(newComposingText, newCursorPosition, + Color.TRANSPARENT, newComposingText.length()); + } + + /** + * Equivalent to {@link #setComposingTextInternal(CharSequence, int)} except that this method + * allows to set {@link BackgroundColorSpan} to the composing text with the given color. + * + * <p>TODO: Currently the background color is exclusive with the black underline, which is + * automatically added by the framework. We need to change the framework if we need to have both + * of them at the same time.</p> + * <p>TODO: Should we move this method to {@link RichInputConnection}?</p> + * + * @param newComposingText the composing text to be set + * @param newCursorPosition the new cursor position + * @param backgroundColor the background color to be set to the composing text. Set + * {@link Color#TRANSPARENT} to disable the background color. + * @param coloredTextLength the length of text, in Java chars, which should be rendered with + * the given background color. + */ + private void setComposingTextInternalWithBackgroundColor(final CharSequence newComposingText, + final int newCursorPosition, final int backgroundColor, final int coloredTextLength) { + final CharSequence composingTextToBeSet; + if (backgroundColor == Color.TRANSPARENT) { + composingTextToBeSet = newComposingText; + } else { + final SpannableString spannable = new SpannableString(newComposingText); + final BackgroundColorSpan backgroundColorSpan = + new BackgroundColorSpan(backgroundColor); + final int spanLength = Math.min(coloredTextLength, spannable.length()); + spannable.setSpan(backgroundColorSpan, 0, spanLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING); + composingTextToBeSet = spannable; + } + mConnection.setComposingText(composingTextToBeSet, newCursorPosition); + } + + /** + * Gets an object allowing private IME commands to be sent to the + * underlying editor. + * @return An object for sending private commands to the underlying editor. + */ + public PrivateCommandPerformer getPrivateCommandPerformer() { + return mConnection; + } + + /** + * Gets the expected index of the first char of the composing span within the editor's text. + * Returns a negative value in case there appears to be no valid composing span. + * + * @see #getComposingLength() + * @see RichInputConnection#hasSelection() + * @see RichInputConnection#isCursorPositionKnown() + * @see RichInputConnection#getExpectedSelectionStart() + * @see RichInputConnection#getExpectedSelectionEnd() + * @return The expected index in Java chars of the first char of the composing span. + */ + // TODO: try and see if we can get rid of this method. Ideally the users of this class should + // never need to know this. + public int getComposingStart() { + if (!mConnection.isCursorPositionKnown() || mConnection.hasSelection()) { + return -1; + } + return mConnection.getExpectedSelectionStart() - mWordComposer.size(); + } + + /** + * Gets the expected length in Java chars of the composing span. + * May be 0 if there is no valid composing span. + * @see #getComposingStart() + * @return The expected length of the composing span. + */ + // TODO: try and see if we can get rid of this method. Ideally the users of this class should + // never need to know this. + public int getComposingLength() { + return mWordComposer.size(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogicHandler.java b/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogicHandler.java new file mode 100644 index 000000000..513d8785c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/inputlogic/InputLogicHandler.java @@ -0,0 +1,221 @@ +/* + * 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 org.kelar.inputmethod.latin.inputlogic; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; + +import org.kelar.inputmethod.compat.LooperCompatUtils; +import org.kelar.inputmethod.latin.LatinIME; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.Suggest.OnGetSuggestedWordsCallback; +import org.kelar.inputmethod.latin.common.InputPointers; + +/** + * A helper to manage deferred tasks for the input logic. + */ +class InputLogicHandler implements Handler.Callback { + final Handler mNonUIThreadHandler; + // TODO: remove this reference. + final LatinIME mLatinIME; + final InputLogic mInputLogic; + private final Object mLock = new Object(); + private boolean mInBatchInput; // synchronized using {@link #mLock}. + + private static final int MSG_GET_SUGGESTED_WORDS = 1; + + // A handler that never does anything. This is used for cases where events come before anything + // is initialized, though probably only the monkey can actually do this. + public static final InputLogicHandler NULL_HANDLER = new InputLogicHandler() { + @Override + public void reset() {} + @Override + public boolean handleMessage(final Message msg) { return true; } + @Override + public void onStartBatchInput() {} + @Override + public void onUpdateBatchInput(final InputPointers batchPointers, + final int sequenceNumber) {} + @Override + public void onCancelBatchInput() {} + @Override + public void updateTailBatchInput(final InputPointers batchPointers, + final int sequenceNumber) {} + @Override + public void getSuggestedWords(final int sessionId, final int sequenceNumber, + final OnGetSuggestedWordsCallback callback) {} + }; + + InputLogicHandler() { + mNonUIThreadHandler = null; + mLatinIME = null; + mInputLogic = null; + } + + public InputLogicHandler(final LatinIME latinIME, final InputLogic inputLogic) { + final HandlerThread handlerThread = new HandlerThread( + InputLogicHandler.class.getSimpleName()); + handlerThread.start(); + mNonUIThreadHandler = new Handler(handlerThread.getLooper(), this); + mLatinIME = latinIME; + mInputLogic = inputLogic; + } + + public void reset() { + mNonUIThreadHandler.removeCallbacksAndMessages(null); + } + + // In unit tests, we create several instances of LatinIME, which results in several instances + // of InputLogicHandler. To avoid these handlers lingering, we call this. + public void destroy() { + LooperCompatUtils.quitSafely(mNonUIThreadHandler.getLooper()); + } + + /** + * Handle a message. + * @see android.os.Handler.Callback#handleMessage(android.os.Message) + */ + // Called on the Non-UI handler thread by the Handler code. + @Override + public boolean handleMessage(final Message msg) { + switch (msg.what) { + case MSG_GET_SUGGESTED_WORDS: + mLatinIME.getSuggestedWords(msg.arg1 /* inputStyle */, + msg.arg2 /* sequenceNumber */, (OnGetSuggestedWordsCallback) msg.obj); + break; + } + return true; + } + + // Called on the UI thread by InputLogic. + public void onStartBatchInput() { + synchronized (mLock) { + mInBatchInput = true; + } + } + + public boolean isInBatchInput() { + return mInBatchInput; + } + + /** + * Fetch suggestions corresponding to an update of a batch input. + * @param batchPointers the updated pointers, including the part that was passed last time. + * @param sequenceNumber the sequence number associated with this batch input. + * @param isTailBatchInput true if this is the end of a batch input, false if it's an update. + */ + // This method can be called from any thread and will see to it that the correct threads + // are used for parts that require it. This method will send a message to the Non-UI handler + // thread to pull suggestions, and get the inlined callback to get called on the Non-UI + // handler thread. If this is the end of a batch input, the callback will then proceed to + // send a message to the UI handler in LatinIME so that showing suggestions can be done on + // the UI thread. + private void updateBatchInput(final InputPointers batchPointers, + final int sequenceNumber, final boolean isTailBatchInput) { + synchronized (mLock) { + if (!mInBatchInput) { + // Batch input has ended or canceled while the message was being delivered. + return; + } + mInputLogic.mWordComposer.setBatchInputPointers(batchPointers); + final OnGetSuggestedWordsCallback callback = new OnGetSuggestedWordsCallback() { + @Override + public void onGetSuggestedWords(final SuggestedWords suggestedWords) { + showGestureSuggestionsWithPreviewVisuals(suggestedWords, isTailBatchInput); + } + }; + getSuggestedWords(isTailBatchInput ? SuggestedWords.INPUT_STYLE_TAIL_BATCH + : SuggestedWords.INPUT_STYLE_UPDATE_BATCH, sequenceNumber, callback); + } + } + + void showGestureSuggestionsWithPreviewVisuals(final SuggestedWords suggestedWordsForBatchInput, + final boolean isTailBatchInput) { + final SuggestedWords suggestedWordsToShowSuggestions; + // We're now inside the callback. This always runs on the Non-UI thread, + // no matter what thread updateBatchInput was originally called on. + if (suggestedWordsForBatchInput.isEmpty()) { + // Use old suggestions if we don't have any new ones. + // Previous suggestions are found in InputLogic#mSuggestedWords. + // Since these are the most recent ones and we just recomputed + // new ones to update them, then the previous ones are there. + suggestedWordsToShowSuggestions = mInputLogic.mSuggestedWords; + } else { + suggestedWordsToShowSuggestions = suggestedWordsForBatchInput; + } + mLatinIME.mHandler.showGesturePreviewAndSuggestionStrip(suggestedWordsToShowSuggestions, + isTailBatchInput /* dismissGestureFloatingPreviewText */); + if (isTailBatchInput) { + mInBatchInput = false; + // The following call schedules onEndBatchInputInternal + // to be called on the UI thread. + mLatinIME.mHandler.showTailBatchInputResult(suggestedWordsToShowSuggestions); + } + } + + /** + * Update a batch input. + * + * This fetches suggestions and updates the suggestion strip and the floating text preview. + * + * @param batchPointers the updated batch pointers. + * @param sequenceNumber the sequence number associated with this batch input. + */ + // Called on the UI thread by InputLogic. + public void onUpdateBatchInput(final InputPointers batchPointers, + final int sequenceNumber) { + updateBatchInput(batchPointers, sequenceNumber, false /* isTailBatchInput */); + } + + /** + * Cancel a batch input. + * + * Note that as opposed to updateTailBatchInput, we do the UI side of this immediately on the + * same thread, rather than get this to call a method in LatinIME. This is because + * canceling a batch input does not necessitate the long operation of pulling suggestions. + */ + // Called on the UI thread by InputLogic. + public void onCancelBatchInput() { + synchronized (mLock) { + mInBatchInput = false; + } + } + + /** + * Trigger an update for a tail batch input. + * + * A tail batch input is the last update for a gesture, the one that is triggered after the + * user lifts their finger. This method schedules fetching suggestions on the non-UI thread, + * then when the suggestions are computed it comes back on the UI thread to update the + * suggestion strip, commit the first suggestion, and dismiss the floating text preview. + * + * @param batchPointers the updated batch pointers. + * @param sequenceNumber the sequence number associated with this batch input. + */ + // Called on the UI thread by InputLogic. + public void updateTailBatchInput(final InputPointers batchPointers, + final int sequenceNumber) { + updateBatchInput(batchPointers, sequenceNumber, true /* isTailBatchInput */); + } + + public void getSuggestedWords(final int inputStyle, final int sequenceNumber, + final OnGetSuggestedWordsCallback callback) { + mNonUIThreadHandler.obtainMessage( + MSG_GET_SUGGESTED_WORDS, inputStyle, sequenceNumber, callback).sendToTarget(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/inputlogic/PrivateCommandPerformer.java b/java/src/org/kelar/inputmethod/latin/inputlogic/PrivateCommandPerformer.java new file mode 100644 index 000000000..5babf3226 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/inputlogic/PrivateCommandPerformer.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.inputlogic; + +import android.os.Bundle; + +/** + * Provides an interface matching + * {@link android.view.inputmethod.InputConnection#performPrivateCommand(String,Bundle)}. + */ +public interface PrivateCommandPerformer { + /** + * API to send private commands from an input method to its connected + * editor. This can be used to provide domain-specific features that are + * only known between certain input methods and their clients. + * + * @param action Name of the command to be performed. This must be a scoped + * name, i.e. prefixed with a package name you own, so that + * different developers will not create conflicting commands. + * @param data Any data to include with the command. + * @return true if the command was sent (regardless of whether the + * associated editor understood it), false if the input connection is no + * longer valid. + */ + boolean performPrivateCommand(String action, Bundle data); +} diff --git a/java/src/org/kelar/inputmethod/latin/inputlogic/SpaceState.java b/java/src/org/kelar/inputmethod/latin/inputlogic/SpaceState.java new file mode 100644 index 000000000..0367cb606 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/inputlogic/SpaceState.java @@ -0,0 +1,54 @@ +/* + * 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 org.kelar.inputmethod.latin.inputlogic; + +/** + * Class for managing space states. + * + * At any given time, the input logic is in one of five possible space states. Depending on the + * current space state, some behavior will change; the prime example of this is the PHANTOM state, + * in which any subsequent letter input will input a space before the letter. Read on the + * description inside this class for each of the space states. + */ +public class SpaceState { + // None: the state where all the keyboard behavior is the most "standard" and no automatic + // input is added or removed. In this state, all self-inserting keys only insert themselves, + // and backspace removes one character. + public static final int NONE = 0; + // Double space: the state where the user pressed space twice quickly, which LatinIME + // resolved as period-space. In this state, pressing backspace will undo the + // double-space-to-period insertion: it will replace ". " with " ". + public static final int DOUBLE = 1; + // Swap punctuation: the state where a weak space and a punctuation from the suggestion strip + // have just been swapped. In this state, pressing backspace will undo the swap: the + // characters will be swapped back back, and the space state will go to WEAK. + public static final int SWAP_PUNCTUATION = 2; + // Weak space: a space that should be swapped only by suggestion strip punctuation. Weak + // spaces happen when the user presses space, accepting the current suggestion (whether + // it's an auto-correction or not). In this state, pressing a punctuation from the suggestion + // strip inserts it before the space (while it inserts it after the space in the NONE state). + public static final int WEAK = 3; + // Phantom space: a not-yet-inserted space that should get inserted on the next input, + // character provided it's not a separator. If it's a separator, the phantom space is dropped. + // Phantom spaces happen when a user chooses a word from the suggestion strip. In this state, + // non-separators insert a space before they get inserted. + public static final int PHANTOM = 4; + + private SpaceState() { + // This class is not publicly instantiable. + } +} diff --git a/java/src/org/kelar/inputmethod/latin/makedict/DictionaryHeader.java b/java/src/org/kelar/inputmethod/latin/makedict/DictionaryHeader.java new file mode 100644 index 000000000..6d771af61 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/DictionaryHeader.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.latin.makedict.FormatSpec.DictionaryOptions; +import org.kelar.inputmethod.latin.makedict.FormatSpec.FormatOptions; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Class representing dictionary header. + */ +public final class DictionaryHeader { + public final int mBodyOffset; + @Nonnull + public final DictionaryOptions mDictionaryOptions; + @Nonnull + public final FormatOptions mFormatOptions; + @Nonnull + public final String mLocaleString; + @Nonnull + public final String mVersionString; + @Nonnull + public final String mIdString; + + // Note that these are corresponding definitions in native code in latinime::HeaderPolicy + // and latinime::HeaderReadWriteUtils. + // TODO: Standardize the key names and bump up the format version, taking care not to + // break format version 2 dictionaries. + public static final String DICTIONARY_VERSION_KEY = "version"; + public static final String DICTIONARY_LOCALE_KEY = "locale"; + public static final String DICTIONARY_ID_KEY = "dictionary"; + public static final String DICTIONARY_DESCRIPTION_KEY = "description"; + public static final String DICTIONARY_DATE_KEY = "date"; + public static final String HAS_HISTORICAL_INFO_KEY = "HAS_HISTORICAL_INFO"; + public static final String USES_FORGETTING_CURVE_KEY = "USES_FORGETTING_CURVE"; + public static final String FORGETTING_CURVE_PROBABILITY_VALUES_TABLE_ID_KEY = + "FORGETTING_CURVE_PROBABILITY_VALUES_TABLE_ID"; + public static final String MAX_UNIGRAM_COUNT_KEY = "MAX_UNIGRAM_ENTRY_COUNT"; + public static final String MAX_BIGRAM_COUNT_KEY = "MAX_BIGRAM_ENTRY_COUNT"; + public static final String MAX_TRIGRAM_COUNT_KEY = "MAX_TRIGRAM_ENTRY_COUNT"; + public static final String ATTRIBUTE_VALUE_TRUE = "1"; + public static final String CODE_POINT_TABLE_KEY = "codePointTable"; + + public DictionaryHeader(final int headerSize, + @Nonnull final DictionaryOptions dictionaryOptions, + @Nonnull final FormatOptions formatOptions) throws UnsupportedFormatException { + mDictionaryOptions = dictionaryOptions; + mFormatOptions = formatOptions; + mBodyOffset = formatOptions.mVersion < FormatSpec.VERSION4 ? headerSize : 0; + final String localeString = dictionaryOptions.mAttributes.get(DICTIONARY_LOCALE_KEY); + if (null == localeString) { + throw new UnsupportedFormatException("Cannot create a FileHeader without a locale"); + } + final String versionString = dictionaryOptions.mAttributes.get(DICTIONARY_VERSION_KEY); + if (null == versionString) { + throw new UnsupportedFormatException( + "Cannot create a FileHeader without a version"); + } + final String idString = dictionaryOptions.mAttributes.get(DICTIONARY_ID_KEY); + if (null == idString) { + throw new UnsupportedFormatException("Cannot create a FileHeader without an ID"); + } + mLocaleString = localeString; + mVersionString = versionString; + mIdString = idString; + } + + // Helper method to get the description + @Nullable + public String getDescription() { + // TODO: Right now each dictionary file comes with a description in its own language. + // It will display as is no matter the device's locale. It should be internationalized. + return mDictionaryOptions.mAttributes.get(DICTIONARY_DESCRIPTION_KEY); + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/makedict/FormatSpec.java b/java/src/org/kelar/inputmethod/latin/makedict/FormatSpec.java new file mode 100644 index 000000000..35ed0c7ec --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/FormatSpec.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; + +import java.util.Date; +import java.util.HashMap; + +/** + * Dictionary File Format Specification. + */ +public final class FormatSpec { + + /* + * File header layout is as follows: + * + * v | + * e | MAGIC_NUMBER + version of the file format, 2 bytes. + * r | + * sion + * + * o | + * p | not used, 2 bytes. + * o | + * nflags + * + * h | + * e | size of the file header, 4bytes + * a | including the size of the magic number, the option flags and the header size + * d | + * ersize + * + * attributes list + * + * attributes list is: + * <key> = | string of characters at the char format described below, with the terminator used + * | to signal the end of the string. + * <value> = | string of characters at the char format described below, with the terminator used + * | to signal the end of the string. + * if the size of already read < headersize, goto key. + * + */ + + /* + * Node array (FusionDictionary.PtNodeArray) layout is as follows: + * + * n | + * o | the number of PtNodes, 1 or 2 bytes. + * d | 1 byte = bbbbbbbb match + * e | case 1xxxxxxx => xxxxxxx << 8 + next byte + * c | otherwise => bbbbbbbb + * o | + * unt + * + * n | + * o | sequence of PtNodes, + * d | the layout of each PtNode is described below. + * e | + * s + * + * f | + * o | forward link address, 3byte + * r | 1 byte = bbbbbbbb match + * w | case 1xxxxxxx => -((xxxxxxx << 16) + (next byte << 8) + next byte) + * a | otherwise => (xxxxxxx << 16) + (next byte << 8) + next byte + * r | + * dlinkaddress + */ + + /* Node (FusionDictionary.PtNode) layout is as follows: + * | CHILDREN_ADDRESS_TYPE 2 bits, 11 : FLAG_CHILDREN_ADDRESS_TYPE_THREEBYTES + * | 10 : FLAG_CHILDREN_ADDRESS_TYPE_TWOBYTES + * f | 01 : FLAG_CHILDREN_ADDRESS_TYPE_ONEBYTE + * l | 00 : FLAG_CHILDREN_ADDRESS_TYPE_NOADDRESS + * a | has several chars ? 1 bit, 1 = yes, 0 = no : FLAG_HAS_MULTIPLE_CHARS + * g | has a terminal ? 1 bit, 1 = yes, 0 = no : FLAG_IS_TERMINAL + * s | has shortcut targets ? 1 bit, 1 = yes, 0 = no : FLAG_HAS_SHORTCUT_TARGETS + * | has bigrams ? 1 bit, 1 = yes, 0 = no : FLAG_HAS_BIGRAMS + * | is not a word ? 1 bit, 1 = yes, 0 = no : FLAG_IS_NOT_A_WORD + * | is possibly offensive ? 1 bit, 1 = yes, 0 = no : FLAG_IS_POSSIBLY_OFFENSIVE + * + * c | IF FLAG_HAS_MULTIPLE_CHARS + * h | char, char, char, char n * (1 or 3 bytes) : use PtNodeInfo for i/o helpers + * a | end 1 byte, = 0 + * r | ELSE + * s | char 1 or 3 bytes + * | END + * + * f | + * r | IF FLAG_IS_TERMINAL + * e | frequency 1 byte + * q | + * + * c | + * h | children address, CHILDREN_ADDRESS_TYPE bytes + * i | This address is relative to the position of this field. + * l | + * drenaddress + * + * | IF FLAG_IS_TERMINAL && FLAG_HAS_SHORTCUT_TARGETS + * | shortcut string list + * | IF FLAG_IS_TERMINAL && FLAG_HAS_BIGRAMS + * | bigrams address list + * + * Char format is: + * 1 byte = bbbbbbbb match + * case 000xxxxx: xxxxx << 16 + next byte << 8 + next byte + * else: if 00011111 (= 0x1F) : this is the terminator. This is a relevant choice because + * unicode code points range from 0 to 0x10FFFF, so any 3-byte value starting with + * 00011111 would be outside unicode. + * else: iso-latin-1 code + * This allows for the whole unicode range to be encoded, including chars outside of + * the BMP. Also everything in the iso-latin-1 charset is only 1 byte, except control + * characters which should never happen anyway (and still work, but take 3 bytes). + * + * bigram address list is: + * <flags> = | hasNext = 1 bit, 1 = yes, 0 = no : FLAG_BIGRAM_SHORTCUT_ATTR_HAS_NEXT + * | addressSign = 1 bit, : FLAG_BIGRAM_ATTR_OFFSET_NEGATIVE + * | 1 = must take -address, 0 = must take +address + * | xx : mask with MASK_BIGRAM_ATTR_ADDRESS_TYPE + * | addressFormat = 2 bits, 00 = unused : FLAG_BIGRAM_ATTR_ADDRESS_TYPE_ONEBYTE + * | 01 = 1 byte : FLAG_BIGRAM_ATTR_ADDRESS_TYPE_ONEBYTE + * | 10 = 2 bytes : FLAG_BIGRAM_ATTR_ADDRESS_TYPE_TWOBYTES + * | 11 = 3 bytes : FLAG_BIGRAM_ATTR_ADDRESS_TYPE_THREEBYTES + * | 4 bits : frequency : mask with FLAG_BIGRAM_SHORTCUT_ATTR_FREQUENCY + * <address> | IF (01 == FLAG_BIGRAM_ATTR_ADDRESS_TYPE_ONEBYTE == addressFormat) + * | read 1 byte, add top 4 bits + * | ELSIF (10 == FLAG_BIGRAM_ATTR_ADDRESS_TYPE_TWOBYTES == addressFormat) + * | read 2 bytes, add top 4 bits + * | ELSE // 11 == FLAG_BIGRAM_ATTR_ADDRESS_TYPE_THREEBYTES == addressFormat + * | read 3 bytes, add top 4 bits + * | END + * | if (FLAG_BIGRAM_ATTR_OFFSET_NEGATIVE) then address = -address + * if (FLAG_BIGRAM_SHORTCUT_ATTR_HAS_NEXT) goto bigram_and_shortcut_address_list_is + * + * shortcut string list is: + * <byte size> = PTNODE_SHORTCUT_LIST_SIZE_SIZE bytes, big-endian: size of the list, in bytes. + * <flags> = | hasNext = 1 bit, 1 = yes, 0 = no : FLAG_BIGRAM_SHORTCUT_ATTR_HAS_NEXT + * | reserved = 3 bits, must be 0 + * | 4 bits : frequency : mask with FLAG_BIGRAM_SHORTCUT_ATTR_FREQUENCY + * <shortcut> = | string of characters at the char format described above, with the terminator + * | used to signal the end of the string. + * if (FLAG_BIGRAM_SHORTCUT_ATTR_HAS_NEXT goto flags + */ + + public static final int MAGIC_NUMBER = 0x9BC13AFE; + static final int NOT_A_VERSION_NUMBER = -1; + + // These MUST have the same values as the relevant constants in format_utils.h. + // From version 2.01 on, we use version * 100 + revision as a version number. That allows + // us to change the format during development while having testing devices remove + // older files with each upgrade, while still having a readable versioning scheme. + // When we bump up the dictionary format version, we should update + // ExpandableDictionary.needsToMigrateDictionary() and + // ExpandableDictionary.matchesExpectedBinaryDictFormatVersionForThisType(). + public static final int VERSION2 = 2; + public static final int VERSION201 = 201; + public static final int VERSION202 = 202; + // format version for Fava Dictionaries. + public static final int VERSION_DELIGHT3 = 86736212; + public static final int MINIMUM_SUPPORTED_VERSION_OF_CODE_POINT_TABLE = VERSION201; + // Dictionary version used for testing. + public static final int VERSION4_ONLY_FOR_TESTING = 399; + public static final int VERSION402 = 402; + public static final int VERSION403 = 403; + public static final int VERSION4 = VERSION403; + public static final int MINIMUM_SUPPORTED_STATIC_VERSION = VERSION202; + public static final int MAXIMUM_SUPPORTED_STATIC_VERSION = VERSION_DELIGHT3; + static final int MINIMUM_SUPPORTED_DYNAMIC_VERSION = VERSION4; + static final int MAXIMUM_SUPPORTED_DYNAMIC_VERSION = VERSION403; + + // TODO: Make this value adaptative to content data, store it in the header, and + // use it in the reading code. + static final int MAX_WORD_LENGTH = DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH; + + // These flags are used only in the static dictionary. + static final int MASK_CHILDREN_ADDRESS_TYPE = 0xC0; + static final int FLAG_CHILDREN_ADDRESS_TYPE_NOADDRESS = 0x00; + static final int FLAG_CHILDREN_ADDRESS_TYPE_ONEBYTE = 0x40; + static final int FLAG_CHILDREN_ADDRESS_TYPE_TWOBYTES = 0x80; + static final int FLAG_CHILDREN_ADDRESS_TYPE_THREEBYTES = 0xC0; + + static final int FLAG_HAS_MULTIPLE_CHARS = 0x20; + + static final int FLAG_IS_TERMINAL = 0x10; + static final int FLAG_HAS_SHORTCUT_TARGETS = 0x08; + static final int FLAG_HAS_BIGRAMS = 0x04; + static final int FLAG_IS_NOT_A_WORD = 0x02; + static final int FLAG_IS_POSSIBLY_OFFENSIVE = 0x01; + + static final int FLAG_BIGRAM_SHORTCUT_ATTR_HAS_NEXT = 0x80; + static final int FLAG_BIGRAM_ATTR_OFFSET_NEGATIVE = 0x40; + static final int MASK_BIGRAM_ATTR_ADDRESS_TYPE = 0x30; + static final int FLAG_BIGRAM_ATTR_ADDRESS_TYPE_ONEBYTE = 0x10; + static final int FLAG_BIGRAM_ATTR_ADDRESS_TYPE_TWOBYTES = 0x20; + static final int FLAG_BIGRAM_ATTR_ADDRESS_TYPE_THREEBYTES = 0x30; + static final int FLAG_BIGRAM_SHORTCUT_ATTR_FREQUENCY = 0x0F; + + static final int PTNODE_CHARACTERS_TERMINATOR = 0x1F; + + static final int PTNODE_TERMINATOR_SIZE = 1; + static final int PTNODE_FLAGS_SIZE = 1; + static final int PTNODE_FREQUENCY_SIZE = 1; + static final int PTNODE_MAX_ADDRESS_SIZE = 3; + static final int PTNODE_ATTRIBUTE_FLAGS_SIZE = 1; + static final int PTNODE_ATTRIBUTE_MAX_ADDRESS_SIZE = 3; + static final int PTNODE_SHORTCUT_LIST_SIZE_SIZE = 2; + + static final int NO_CHILDREN_ADDRESS = Integer.MIN_VALUE; + static final int INVALID_CHARACTER = -1; + + static final int MAX_PTNODES_FOR_ONE_BYTE_PTNODE_COUNT = 0x7F; // 127 + // Large PtNode array size field size is 2 bytes. + static final int LARGE_PTNODE_ARRAY_SIZE_FIELD_SIZE_FLAG = 0x8000; + static final int MAX_PTNODES_IN_A_PT_NODE_ARRAY = 0x7FFF; // 32767 + static final int MAX_BIGRAMS_IN_A_PTNODE = 10000; + static final int MAX_SHORTCUT_LIST_SIZE_IN_A_PTNODE = 0xFFFF; + + static final int MAX_TERMINAL_FREQUENCY = 255; + static final int MAX_BIGRAM_FREQUENCY = 15; + + public static final int SHORTCUT_WHITELIST_FREQUENCY = 15; + + // This option needs to be the same numeric value as the one in binary_format.h. + static final int NOT_VALID_WORD = -99; + + static final int UINT8_MAX = 0xFF; + static final int UINT16_MAX = 0xFFFF; + static final int UINT24_MAX = 0xFFFFFF; + static final int MSB8 = 0x80; + static final int MINIMAL_ONE_BYTE_CHARACTER_VALUE = 0x20; + static final int MAXIMAL_ONE_BYTE_CHARACTER_VALUE = 0xFF; + + /** + * Options about file format. + */ + public static final class FormatOptions { + public final int mVersion; + public final boolean mHasTimestamp; + + @UsedForTesting + public FormatOptions(final int version) { + this(version, false /* hasTimestamp */); + } + + public FormatOptions(final int version, final boolean hasTimestamp) { + mVersion = version; + mHasTimestamp = hasTimestamp; + } + } + + /** + * Options global to the dictionary. + */ + public static final class DictionaryOptions { + public final HashMap<String, String> mAttributes; + public DictionaryOptions(final HashMap<String, String> attributes) { + mAttributes = attributes; + } + @Override + public String toString() { // Convenience method + return toString(0, false); + } + public String toString(final int indentCount, final boolean plumbing) { + final StringBuilder indent = new StringBuilder(); + if (plumbing) { + indent.append("H:"); + } else { + for (int i = 0; i < indentCount; ++i) { + indent.append(" "); + } + } + final StringBuilder s = new StringBuilder(); + for (final String optionKey : mAttributes.keySet()) { + s.append(indent); + s.append(optionKey); + s.append(" = "); + if ("date".equals(optionKey) && !plumbing) { + // Date needs a number of milliseconds, but the dictionary contains seconds + s.append(new Date( + 1000 * Long.parseLong(mAttributes.get(optionKey))).toString()); + } else { + s.append(mAttributes.get(optionKey)); + } + s.append("\n"); + } + return s.toString(); + } + } + + private FormatSpec() { + // This utility class is not publicly instantiable. + } +} diff --git a/java/src/org/kelar/inputmethod/latin/makedict/NgramProperty.java b/java/src/org/kelar/inputmethod/latin/makedict/NgramProperty.java new file mode 100644 index 000000000..a9a762553 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/NgramProperty.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.latin.NgramContext; + +public class NgramProperty { + public final WeightedString mTargetWord; + public final NgramContext mNgramContext; + + public NgramProperty(final WeightedString targetWord, final NgramContext ngramContext) { + mTargetWord = targetWord; + mNgramContext = ngramContext; + } + + @Override + public int hashCode() { + return mTargetWord.hashCode() ^ mNgramContext.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof NgramProperty)) return false; + final NgramProperty n = (NgramProperty)o; + return mTargetWord.equals(n.mTargetWord) && mNgramContext.equals(n.mNgramContext); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/makedict/ProbabilityInfo.java b/java/src/org/kelar/inputmethod/latin/makedict/ProbabilityInfo.java new file mode 100644 index 000000000..bd397191a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/ProbabilityInfo.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.BinaryDictionary; +import org.kelar.inputmethod.latin.utils.CombinedFormatUtils; + +import java.util.Arrays; + +public final class ProbabilityInfo { + public final int mProbability; + // mTimestamp, mLevel and mCount are historical info. These values are depend on the + // implementation in native code; thus, we must not use them and have any assumptions about + // them except for tests. + public final int mTimestamp; + public final int mLevel; + public final int mCount; + + @UsedForTesting + public static ProbabilityInfo max(final ProbabilityInfo probabilityInfo1, + final ProbabilityInfo probabilityInfo2) { + if (probabilityInfo1 == null) { + return probabilityInfo2; + } + if (probabilityInfo2 == null) { + return probabilityInfo1; + } + return (probabilityInfo1.mProbability > probabilityInfo2.mProbability) ? probabilityInfo1 + : probabilityInfo2; + } + + public ProbabilityInfo(final int probability) { + this(probability, BinaryDictionary.NOT_A_VALID_TIMESTAMP, 0, 0); + } + + public ProbabilityInfo(final int probability, final int timestamp, final int level, + final int count) { + mProbability = probability; + mTimestamp = timestamp; + mLevel = level; + mCount = count; + } + + public boolean hasHistoricalInfo() { + return mTimestamp != BinaryDictionary.NOT_A_VALID_TIMESTAMP; + } + + @Override + public int hashCode() { + if (hasHistoricalInfo()) { + return Arrays.hashCode(new Object[] { mProbability, mTimestamp, mLevel, mCount }); + } + return Arrays.hashCode(new Object[] { mProbability }); + } + + @Override + public String toString() { + return CombinedFormatUtils.formatProbabilityInfo(this); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof ProbabilityInfo)) return false; + final ProbabilityInfo p = (ProbabilityInfo)o; + if (!hasHistoricalInfo() && !p.hasHistoricalInfo()) { + return mProbability == p.mProbability; + } + return mProbability == p.mProbability && mTimestamp == p.mTimestamp && mLevel == p.mLevel + && mCount == p.mCount; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/makedict/UnsupportedFormatException.java b/java/src/org/kelar/inputmethod/latin/makedict/UnsupportedFormatException.java new file mode 100644 index 000000000..a8d60e5fb --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/UnsupportedFormatException.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.makedict; + +/** + * Simple exception thrown when a file format is not recognized. + */ +public final class UnsupportedFormatException extends Exception { + public UnsupportedFormatException(String description) { + super(description); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/makedict/WeightedString.java b/java/src/org/kelar/inputmethod/latin/makedict/WeightedString.java new file mode 100644 index 000000000..e2b910b29 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/WeightedString.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.util.Arrays; + +/** + * A string with a probability. + * + * This represents an "attribute", that is either a bigram or a shortcut. + */ +public final class WeightedString { + public final String mWord; + public ProbabilityInfo mProbabilityInfo; + + public WeightedString(final String word, final int probability) { + this(word, new ProbabilityInfo(probability)); + } + + public WeightedString(final String word, final ProbabilityInfo probabilityInfo) { + mWord = word; + mProbabilityInfo = probabilityInfo; + } + + @UsedForTesting + public int getProbability() { + return mProbabilityInfo.mProbability; + } + + public void setProbability(final int probability) { + mProbabilityInfo = new ProbabilityInfo(probability); + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] { mWord, mProbabilityInfo}); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof WeightedString)) return false; + final WeightedString w = (WeightedString)o; + return mWord.equals(w.mWord) && mProbabilityInfo.equals(w.mProbabilityInfo); + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/makedict/WordProperty.java b/java/src/org/kelar/inputmethod/latin/makedict/WordProperty.java new file mode 100644 index 000000000..e28615c40 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/makedict/WordProperty.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.makedict; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.BinaryDictionary; +import org.kelar.inputmethod.latin.Dictionary; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.NgramContext.WordInfo; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.utils.CombinedFormatUtils; + +import java.util.ArrayList; +import java.util.Arrays; + +import javax.annotation.Nullable; + +/** + * Utility class for a word with a probability. + * + * This is chiefly used to iterate a dictionary. + */ +public final class WordProperty implements Comparable<WordProperty> { + public final String mWord; + public final ProbabilityInfo mProbabilityInfo; + public final ArrayList<NgramProperty> mNgrams; + // TODO: Support mIsBeginningOfSentence. + public final boolean mIsBeginningOfSentence; + public final boolean mIsNotAWord; + public final boolean mIsPossiblyOffensive; + public final boolean mHasNgrams; + + private int mHashCode = 0; + + // TODO: Support n-gram. + @UsedForTesting + public WordProperty(final String word, final ProbabilityInfo probabilityInfo, + @Nullable final ArrayList<WeightedString> bigrams, + final boolean isNotAWord, final boolean isPossiblyOffensive) { + mWord = word; + mProbabilityInfo = probabilityInfo; + if (null == bigrams) { + mNgrams = null; + } else { + mNgrams = new ArrayList<>(); + final NgramContext ngramContext = new NgramContext(new WordInfo(mWord)); + for (final WeightedString bigramTarget : bigrams) { + mNgrams.add(new NgramProperty(bigramTarget, ngramContext)); + } + } + mIsBeginningOfSentence = false; + mIsNotAWord = isNotAWord; + mIsPossiblyOffensive = isPossiblyOffensive; + mHasNgrams = bigrams != null && !bigrams.isEmpty(); + } + + private static ProbabilityInfo createProbabilityInfoFromArray(final int[] probabilityInfo) { + return new ProbabilityInfo( + probabilityInfo[BinaryDictionary.FORMAT_WORD_PROPERTY_PROBABILITY_INDEX], + probabilityInfo[BinaryDictionary.FORMAT_WORD_PROPERTY_TIMESTAMP_INDEX], + probabilityInfo[BinaryDictionary.FORMAT_WORD_PROPERTY_LEVEL_INDEX], + probabilityInfo[BinaryDictionary.FORMAT_WORD_PROPERTY_COUNT_INDEX]); + } + + // Construct word property using information from native code. + // This represents invalid word when the probability is BinaryDictionary.NOT_A_PROBABILITY. + public WordProperty(final int[] codePoints, final boolean isNotAWord, + final boolean isPossiblyOffensive, final boolean hasBigram, + final boolean isBeginningOfSentence, final int[] probabilityInfo, + final ArrayList<int[][]> ngramPrevWordsArray, + final ArrayList<boolean[]> ngramPrevWordIsBeginningOfSentenceArray, + final ArrayList<int[]> ngramTargets, final ArrayList<int[]> ngramProbabilityInfo) { + mWord = StringUtils.getStringFromNullTerminatedCodePointArray(codePoints); + mProbabilityInfo = createProbabilityInfoFromArray(probabilityInfo); + final ArrayList<NgramProperty> ngrams = new ArrayList<>(); + mIsBeginningOfSentence = isBeginningOfSentence; + mIsNotAWord = isNotAWord; + mIsPossiblyOffensive = isPossiblyOffensive; + mHasNgrams = hasBigram; + + final int relatedNgramCount = ngramTargets.size(); + for (int i = 0; i < relatedNgramCount; i++) { + final String ngramTargetString = + StringUtils.getStringFromNullTerminatedCodePointArray(ngramTargets.get(i)); + final WeightedString ngramTarget = new WeightedString(ngramTargetString, + createProbabilityInfoFromArray(ngramProbabilityInfo.get(i))); + final int[][] prevWords = ngramPrevWordsArray.get(i); + final boolean[] isBeginningOfSentenceArray = + ngramPrevWordIsBeginningOfSentenceArray.get(i); + final WordInfo[] wordInfoArray = new WordInfo[prevWords.length]; + for (int j = 0; j < prevWords.length; j++) { + wordInfoArray[j] = isBeginningOfSentenceArray[j] + ? WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO + : new WordInfo(StringUtils.getStringFromNullTerminatedCodePointArray( + prevWords[j])); + } + final NgramContext ngramContext = new NgramContext(wordInfoArray); + ngrams.add(new NgramProperty(ngramTarget, ngramContext)); + } + mNgrams = ngrams.isEmpty() ? null : ngrams; + } + + // TODO: Remove + @UsedForTesting + public ArrayList<WeightedString> getBigrams() { + if (null == mNgrams) { + return null; + } + final ArrayList<WeightedString> bigrams = new ArrayList<>(); + for (final NgramProperty ngram : mNgrams) { + if (ngram.mNgramContext.getPrevWordCount() == 1) { + bigrams.add(ngram.mTargetWord); + } + } + return bigrams; + } + + public int getProbability() { + return mProbabilityInfo.mProbability; + } + + private static int computeHashCode(WordProperty word) { + return Arrays.hashCode(new Object[] { + word.mWord, + word.mProbabilityInfo, + word.mNgrams, + word.mIsNotAWord, + word.mIsPossiblyOffensive + }); + } + + /** + * Three-way comparison. + * + * A Word x is greater than a word y if x has a higher frequency. If they have the same + * frequency, they are sorted in lexicographic order. + */ + @Override + public int compareTo(final WordProperty w) { + if (getProbability() < w.getProbability()) return 1; + if (getProbability() > w.getProbability()) return -1; + return mWord.compareTo(w.mWord); + } + + /** + * Equality test. + * + * Words are equal if they have the same frequency, the same spellings, and the same + * attributes. + */ + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (!(o instanceof WordProperty)) return false; + WordProperty w = (WordProperty)o; + return mProbabilityInfo.equals(w.mProbabilityInfo) + && mWord.equals(w.mWord) && equals(mNgrams, w.mNgrams) + && mIsNotAWord == w.mIsNotAWord && mIsPossiblyOffensive == w.mIsPossiblyOffensive + && mHasNgrams == w.mHasNgrams; + } + + // TDOO: Have a utility method like java.util.Objects.equals. + private static <T> boolean equals(final ArrayList<T> a, final ArrayList<T> b) { + if (null == a) { + return null == b; + } + return a.equals(b); + } + + @Override + public int hashCode() { + if (mHashCode == 0) { + mHashCode = computeHashCode(this); + } + return mHashCode; + } + + @UsedForTesting + public boolean isValid() { + return getProbability() != Dictionary.NOT_A_PROBABILITY; + } + + @Override + public String toString() { + return CombinedFormatUtils.formatWordProperty(this); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/network/AuthException.java b/java/src/org/kelar/inputmethod/latin/network/AuthException.java new file mode 100644 index 000000000..1df92e8cb --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/network/AuthException.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.network; + +/** + * Authentication exception. When this exception is thrown, the client may + * try to refresh the authentication token and try again. + */ +public class AuthException extends Exception { + public AuthException() { + super(); + } + + public AuthException(Throwable throwable) { + super(throwable); + } + + public AuthException(String detailMessage) { + super(detailMessage); + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/network/BlockingHttpClient.java b/java/src/org/kelar/inputmethod/latin/network/BlockingHttpClient.java new file mode 100644 index 000000000..7ae3860e7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/network/BlockingHttpClient.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.network; + +import android.util.Log; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A client for executing HTTP requests synchronously. + * This must never be called from the main thread. + */ +public class BlockingHttpClient { + private static final boolean DEBUG = false; + private static final String TAG = BlockingHttpClient.class.getSimpleName(); + + private final HttpURLConnection mConnection; + + /** + * Interface that handles processing the response for a request. + */ + public interface ResponseProcessor<T> { + /** + * Called when the HTTP request finishes successfully. + * The {@link InputStream} is closed by the client after the method finishes, + * so any processing must be done in this method itself. + * + * @param response An input stream that can be used to read the HTTP response. + */ + T onSuccess(InputStream response) throws IOException; + } + + public BlockingHttpClient(HttpURLConnection connection) { + mConnection = connection; + } + + /** + * Executes the request on the underlying {@link HttpURLConnection}. + * + * @param request The request payload, if any, or null. + * @param responseProcessor A processor for the HTTP response. + */ + public <T> T execute(@Nullable byte[] request, @Nonnull ResponseProcessor<T> responseProcessor) + throws IOException, AuthException, HttpException { + if (DEBUG) { + Log.d(TAG, "execute: " + mConnection.getURL()); + } + try { + if (request != null) { + if (DEBUG) { + Log.d(TAG, "request size: " + request.length); + } + OutputStream out = new BufferedOutputStream(mConnection.getOutputStream()); + out.write(request); + out.flush(); + out.close(); + } + + final int responseCode = mConnection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "Response error: " + responseCode + ", Message: " + + mConnection.getResponseMessage()); + if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + throw new AuthException(mConnection.getResponseMessage()); + } + throw new HttpException(responseCode); + } + if (DEBUG) { + Log.d(TAG, "request executed successfully"); + } + return responseProcessor.onSuccess(mConnection.getInputStream()); + } finally { + mConnection.disconnect(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/network/HttpException.java b/java/src/org/kelar/inputmethod/latin/network/HttpException.java new file mode 100644 index 000000000..6413b0667 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/network/HttpException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.network; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +/** + * The HttpException exception represents a XML/HTTP fault with a HTTP status code. + */ +public class HttpException extends Exception { + + /** + * The HTTP status code. + */ + private final int mStatusCode; + + /** + * @param statusCode int HTTP status code. + */ + public HttpException(int statusCode) { + super("Response Code: " + statusCode); + mStatusCode = statusCode; + } + + /** + * @return the HTTP status code related to this exception. + */ + @UsedForTesting + public int getHttpStatusCode() { + return mStatusCode; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/network/HttpUrlConnectionBuilder.java b/java/src/org/kelar/inputmethod/latin/network/HttpUrlConnectionBuilder.java new file mode 100644 index 000000000..b9f81cbe2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/network/HttpUrlConnectionBuilder.java @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.network; + +import android.text.TextUtils; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map.Entry; + +/** + * Builder for {@link HttpURLConnection}s. + * + * TODO: Remove @UsedForTesting after this is actually used. + */ +@UsedForTesting +public class HttpUrlConnectionBuilder { + private static final int DEFAULT_TIMEOUT_MILLIS = 5 * 1000; + + /** + * Request header key for authentication. + */ + public static final String HTTP_HEADER_AUTHORIZATION = "Authorization"; + + /** + * Request header key for cache control. + */ + public static final String KEY_CACHE_CONTROL = "Cache-Control"; + /** + * Request header value for cache control indicating no caching. + * @see #KEY_CACHE_CONTROL + */ + public static final String VALUE_NO_CACHE = "no-cache"; + + /** + * Indicates that the request is unidirectional - upload-only. + * TODO: Remove @UsedForTesting after this is actually used. + */ + @UsedForTesting + public static final int MODE_UPLOAD_ONLY = 1; + /** + * Indicates that the request is unidirectional - download only. + * TODO: Remove @UsedForTesting after this is actually used. + */ + @UsedForTesting + public static final int MODE_DOWNLOAD_ONLY = 2; + /** + * Indicates that the request is bi-directional. + * TODO: Remove @UsedForTesting after this is actually used. + */ + @UsedForTesting + public static final int MODE_BI_DIRECTIONAL = 3; + + private final HashMap<String, String> mHeaderMap = new HashMap<>(); + + private URL mUrl; + private int mConnectTimeoutMillis = DEFAULT_TIMEOUT_MILLIS; + private int mReadTimeoutMillis = DEFAULT_TIMEOUT_MILLIS; + private int mContentLength = -1; + private boolean mUseCache; + private int mMode; + + /** + * Sets the URL that'll be used for the request. + * This *must* be set before calling {@link #build()} + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setUrl(String url) throws MalformedURLException { + if (TextUtils.isEmpty(url)) { + throw new IllegalArgumentException("URL must not be empty"); + } + mUrl = new URL(url); + return this; + } + + /** + * Sets the connect timeout. Defaults to {@value #DEFAULT_TIMEOUT_MILLIS} milliseconds. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setConnectTimeout(int timeoutMillis) { + if (timeoutMillis < 0) { + throw new IllegalArgumentException("connect-timeout must be >= 0, but was " + + timeoutMillis); + } + mConnectTimeoutMillis = timeoutMillis; + return this; + } + + /** + * Sets the read timeout. Defaults to {@value #DEFAULT_TIMEOUT_MILLIS} milliseconds. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setReadTimeout(int timeoutMillis) { + if (timeoutMillis < 0) { + throw new IllegalArgumentException("read-timeout must be >= 0, but was " + + timeoutMillis); + } + mReadTimeoutMillis = timeoutMillis; + return this; + } + + /** + * Adds an entry to the request header. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder addHeader(String key, String value) { + mHeaderMap.put(key, value); + return this; + } + + /** + * Sets an authentication token. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setAuthToken(String value) { + mHeaderMap.put(HTTP_HEADER_AUTHORIZATION, value); + return this; + } + + /** + * Sets the request to be executed such that the input is not buffered. + * This may be set when the request size is known beforehand. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setFixedLengthForStreaming(int length) { + mContentLength = length; + return this; + } + + /** + * Indicates if the request can use cached responses or not. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpUrlConnectionBuilder setUseCache(boolean useCache) { + mUseCache = useCache; + return this; + } + + /** + * The request mode. + * Sets the request mode to be one of: upload-only, download-only or bidirectional. + * + * @see #MODE_UPLOAD_ONLY + * @see #MODE_DOWNLOAD_ONLY + * @see #MODE_BI_DIRECTIONAL + * + * TODO: Remove @UsedForTesting after this method is actually used + */ + @UsedForTesting + public HttpUrlConnectionBuilder setMode(int mode) { + if (mode != MODE_UPLOAD_ONLY + && mode != MODE_DOWNLOAD_ONLY + && mode != MODE_BI_DIRECTIONAL) { + throw new IllegalArgumentException("Invalid mode specified:" + mode); + } + mMode = mode; + return this; + } + + /** + * Builds the {@link HttpURLConnection} instance that can be used to execute the request. + * + * TODO: Remove @UsedForTesting after this method is actually used. + */ + @UsedForTesting + public HttpURLConnection build() throws IOException { + if (mUrl == null) { + throw new IllegalArgumentException("A URL must be specified!"); + } + final HttpURLConnection connection = (HttpURLConnection) mUrl.openConnection(); + connection.setConnectTimeout(mConnectTimeoutMillis); + connection.setReadTimeout(mReadTimeoutMillis); + connection.setUseCaches(mUseCache); + switch (mMode) { + case MODE_UPLOAD_ONLY: + connection.setDoInput(true); + connection.setDoOutput(false); + break; + case MODE_DOWNLOAD_ONLY: + connection.setDoInput(false); + connection.setDoOutput(true); + break; + case MODE_BI_DIRECTIONAL: + connection.setDoInput(true); + connection.setDoOutput(true); + break; + } + for (final Entry<String, String> entry : mHeaderMap.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } + if (mContentLength >= 0) { + connection.setFixedLengthStreamingMode(mContentLength); + } + return connection; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/permissions/PermissionsActivity.java b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsActivity.java new file mode 100644 index 000000000..5c56a2a10 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsActivity.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin.permissions; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; + +/** + * An activity to help request permissions. It's used when no other activity is available, e.g. in + * InputMethodService. This activity assumes that all permissions are not granted yet. + */ +public final class PermissionsActivity + extends Activity implements ActivityCompat.OnRequestPermissionsResultCallback { + + /** + * Key to retrieve requested permissions from the intent. + */ + public static final String EXTRA_PERMISSION_REQUESTED_PERMISSIONS = "requested_permissions"; + + /** + * Key to retrieve request code from the intent. + */ + public static final String EXTRA_PERMISSION_REQUEST_CODE = "request_code"; + + private static final int INVALID_REQUEST_CODE = -1; + + private int mPendingRequestCode = INVALID_REQUEST_CODE; + + /** + * Starts a PermissionsActivity and checks/requests supplied permissions. + */ + public static void run( + @NonNull Context context, int requestCode, @NonNull String... permissionStrings) { + Intent intent = new Intent(context.getApplicationContext(), PermissionsActivity.class); + intent.putExtra(EXTRA_PERMISSION_REQUESTED_PERMISSIONS, permissionStrings); + intent.putExtra(EXTRA_PERMISSION_REQUEST_CODE, requestCode); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mPendingRequestCode = (savedInstanceState != null) + ? savedInstanceState.getInt(EXTRA_PERMISSION_REQUEST_CODE, INVALID_REQUEST_CODE) + : INVALID_REQUEST_CODE; + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(EXTRA_PERMISSION_REQUEST_CODE, mPendingRequestCode); + } + + @Override + protected void onResume() { + super.onResume(); + // Only do request when there is no pending request to avoid duplicated requests. + if (mPendingRequestCode == INVALID_REQUEST_CODE) { + final Bundle extras = getIntent().getExtras(); + final String[] permissionsToRequest = + extras.getStringArray(EXTRA_PERMISSION_REQUESTED_PERMISSIONS); + mPendingRequestCode = extras.getInt(EXTRA_PERMISSION_REQUEST_CODE); + // Assuming that all supplied permissions are not granted yet, so that we don't need to + // check them again. + PermissionsUtil.requestPermissions(this, mPendingRequestCode, permissionsToRequest); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + mPendingRequestCode = INVALID_REQUEST_CODE; + PermissionsManager.get(this).onRequestPermissionsResult( + requestCode, permissions, grantResults); + finish(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/permissions/PermissionsManager.java b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsManager.java new file mode 100644 index 000000000..d95f4540d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsManager.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin.permissions; + +import android.app.Activity; +import android.content.Context; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Manager to perform permission related tasks. Always call on the UI thread. + */ +public class PermissionsManager { + + public interface PermissionsResultCallback { + void onRequestPermissionsResult(boolean allGranted); + } + + private int mRequestCodeId; + + private final Context mContext; + private final Map<Integer, PermissionsResultCallback> mRequestIdToCallback = new HashMap<>(); + + private static PermissionsManager sInstance; + + public PermissionsManager(Context context) { + mContext = context; + } + + @Nonnull + public static synchronized PermissionsManager get(@Nonnull Context context) { + if (sInstance == null) { + sInstance = new PermissionsManager(context); + } + return sInstance; + } + + private synchronized int getNextRequestId() { + return ++mRequestCodeId; + } + + + public synchronized void requestPermissions(@Nonnull PermissionsResultCallback callback, + @Nullable Activity activity, + String... permissionsToRequest) { + List<String> deniedPermissions = PermissionsUtil.getDeniedPermissions( + mContext, permissionsToRequest); + if (deniedPermissions.isEmpty()) { + return; + } + // otherwise request the permissions. + int requestId = getNextRequestId(); + String[] permissionsArray = deniedPermissions.toArray( + new String[deniedPermissions.size()]); + + mRequestIdToCallback.put(requestId, callback); + if (activity != null) { + PermissionsUtil.requestPermissions(activity, requestId, permissionsArray); + } else { + PermissionsActivity.run(mContext, requestId, permissionsArray); + } + } + + public synchronized void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + PermissionsResultCallback permissionsResultCallback = mRequestIdToCallback.get(requestCode); + mRequestIdToCallback.remove(requestCode); + + boolean allGranted = PermissionsUtil.allGranted(grantResults); + permissionsResultCallback.onRequestPermissionsResult(allGranted); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/permissions/PermissionsUtil.java b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsUtil.java new file mode 100644 index 000000000..337334485 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/permissions/PermissionsUtil.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin.permissions; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for permissions. + */ +public class PermissionsUtil { + + /** + * Returns the list of permissions not granted from the given list of permissions. + * @param context Context + * @param permissions list of permissions to check. + * @return the list of permissions that do not have permission to use. + */ + public static List<String> getDeniedPermissions(Context context, + String... permissions) { + final List<String> deniedPermissions = new ArrayList<>(); + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) + != PackageManager.PERMISSION_GRANTED) { + deniedPermissions.add(permission); + } + } + return deniedPermissions; + } + + /** + * Uses the given activity and requests the user for permissions. + * @param activity activity to use. + * @param requestCode request code/id to use. + * @param permissions String array of permissions that needs to be requested. + */ + public static void requestPermissions(Activity activity, int requestCode, + String[] permissions) { + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } + + /** + * Checks if all the permissions are granted. + */ + public static boolean allGranted(@NonNull int[] grantResults) { + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + /** + * Queries if al the permissions are granted for the given permission strings. + */ + public static boolean checkAllPermissionsGranted(Context context, String... permissions) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { + // For all pre-M devices, we should have all the premissions granted on install. + return true; + } + + for (String permission : permissions) { + if (ContextCompat.checkSelfPermission(context, permission) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/personalization/AccountUtils.java b/java/src/org/kelar/inputmethod/latin/personalization/AccountUtils.java new file mode 100644 index 000000000..45e551291 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/personalization/AccountUtils.java @@ -0,0 +1,66 @@ +/* + * 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 org.kelar.inputmethod.latin.personalization; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class AccountUtils { + private AccountUtils() { + // This utility class is not publicly instantiable. + } + + private static Account[] getAccounts(final Context context) { + return AccountManager.get(context).getAccounts(); + } + + public static List<String> getDeviceAccountsEmailAddresses(final Context context) { + final ArrayList<String> retval = new ArrayList<>(); + for (final Account account : getAccounts(context)) { + final String name = account.name; + if (Patterns.EMAIL_ADDRESS.matcher(name).matches()) { + retval.add(name); + retval.add(name.split("@")[0]); + } + } + return retval; + } + + /** + * Get all device accounts having specified domain name. + * @param context application context + * @param domain domain name used for filtering + * @return List of account names that contain the specified domain name + */ + public static List<String> getDeviceAccountsWithDomain( + final Context context, final String domain) { + final ArrayList<String> retval = new ArrayList<>(); + final String atDomain = "@" + domain.toLowerCase(Locale.ROOT); + for (final Account account : getAccounts(context)) { + if (account.name.toLowerCase(Locale.ROOT).endsWith(atDomain)) { + retval.add(account.name); + } + } + return retval; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/personalization/PersonalizationHelper.java b/java/src/org/kelar/inputmethod/latin/personalization/PersonalizationHelper.java new file mode 100644 index 000000000..7be7d1c8f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/personalization/PersonalizationHelper.java @@ -0,0 +1,108 @@ +/* + * 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 org.kelar.inputmethod.latin.personalization; + +import android.content.Context; +import android.util.Log; + +import org.kelar.inputmethod.latin.common.FileUtils; + +import java.io.File; +import java.io.FilenameFilter; +import java.lang.ref.SoftReference; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Helps handle and manage personalized dictionaries such as {@link UserHistoryDictionary}. + */ +public class PersonalizationHelper { + private static final String TAG = PersonalizationHelper.class.getSimpleName(); + private static final boolean DEBUG = false; + + private static final ConcurrentHashMap<String, SoftReference<UserHistoryDictionary>> + sLangUserHistoryDictCache = new ConcurrentHashMap<>(); + + @Nonnull + public static UserHistoryDictionary getUserHistoryDictionary( + final Context context, final Locale locale, @Nullable final String accountName) { + String lookupStr = locale.toString(); + if (accountName != null) { + lookupStr += "." + accountName; + } + synchronized (sLangUserHistoryDictCache) { + if (sLangUserHistoryDictCache.containsKey(lookupStr)) { + final SoftReference<UserHistoryDictionary> ref = + sLangUserHistoryDictCache.get(lookupStr); + final UserHistoryDictionary dict = ref == null ? null : ref.get(); + if (dict != null) { + if (DEBUG) { + Log.d(TAG, "Use cached UserHistoryDictionary with lookup: " + lookupStr); + } + dict.reloadDictionaryIfRequired(); + return dict; + } + } + final UserHistoryDictionary dict = new UserHistoryDictionary( + context, locale, accountName); + sLangUserHistoryDictCache.put(lookupStr, new SoftReference<>(dict)); + return dict; + } + } + + public static void removeAllUserHistoryDictionaries(final Context context) { + synchronized (sLangUserHistoryDictCache) { + for (final ConcurrentHashMap.Entry<String, SoftReference<UserHistoryDictionary>> entry + : sLangUserHistoryDictCache.entrySet()) { + if (entry.getValue() != null) { + final UserHistoryDictionary dict = entry.getValue().get(); + if (dict != null) { + dict.clear(); + } + } + } + sLangUserHistoryDictCache.clear(); + final File filesDir = context.getFilesDir(); + if (filesDir == null) { + Log.e(TAG, "context.getFilesDir() returned null."); + return; + } + final boolean filesDeleted = FileUtils.deleteFilteredFiles( + filesDir, new DictFilter(UserHistoryDictionary.NAME)); + if (!filesDeleted) { + Log.e(TAG, "Cannot remove dictionary files. filesDir: " + filesDir.getAbsolutePath() + + ", dictNamePrefix: " + UserHistoryDictionary.NAME); + } + } + } + + private static class DictFilter implements FilenameFilter { + private final String mName; + + DictFilter(final String name) { + mName = name; + } + + @Override + public boolean accept(final File dir, final String name) { + return name.startsWith(mName); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/personalization/UserHistoryDictionary.java b/java/src/org/kelar/inputmethod/latin/personalization/UserHistoryDictionary.java new file mode 100644 index 000000000..bbd96c61e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/personalization/UserHistoryDictionary.java @@ -0,0 +1,135 @@ +/* + * 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 org.kelar.inputmethod.latin.personalization; + +import android.content.Context; + +import org.kelar.inputmethod.annotations.ExternallyReferenced; +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.BinaryDictionary; +import org.kelar.inputmethod.latin.Dictionary; +import org.kelar.inputmethod.latin.ExpandableBinaryDictionary; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.define.ProductionFlags; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; + +import java.io.File; +import java.util.Locale; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Locally gathers statistics about the words user types and various other signals like + * auto-correction cancellation or manual picks. This allows the keyboard to adapt to the + * typist over time. + */ +public class UserHistoryDictionary extends ExpandableBinaryDictionary { + static final String NAME = UserHistoryDictionary.class.getSimpleName(); + + // TODO: Make this constructor private + UserHistoryDictionary(final Context context, final Locale locale, + @Nullable final String account) { + super(context, getUserHistoryDictName(NAME, locale, null /* dictFile */, account), locale, Dictionary.TYPE_USER_HISTORY, null); + if (mLocale != null && mLocale.toString().length() > 1) { + reloadDictionaryIfRequired(); + } + } + + /** + * @returns the name of the {@link UserHistoryDictionary}. + */ + @UsedForTesting + static String getUserHistoryDictName(final String name, final Locale locale, + @Nullable final File dictFile, @Nullable final String account) { + if (!ProductionFlags.ENABLE_PER_ACCOUNT_USER_HISTORY_DICTIONARY) { + return getDictName(name, locale, dictFile); + } + return getUserHistoryDictNamePerAccount(name, locale, dictFile, account); + } + + /** + * Uses the currently signed in account to determine the dictionary name. + */ + private static String getUserHistoryDictNamePerAccount(final String name, final Locale locale, + @Nullable final File dictFile, @Nullable final String account) { + if (dictFile != null) { + return dictFile.getName(); + } + String dictName = name + "." + locale.toString(); + if (account != null) { + dictName += "." + account; + } + return dictName; + } + + // Note: This method is called by {@link DictionaryFacilitator} using Java reflection. + @SuppressWarnings("unused") + @ExternallyReferenced + public static UserHistoryDictionary getDictionary(final Context context, final Locale locale, + final File dictFile, final String dictNamePrefix, @Nullable final String account) { + return PersonalizationHelper.getUserHistoryDictionary(context, locale, account); + } + + /** + * Add a word to the user history dictionary. + * + * @param userHistoryDictionary the user history dictionary + * @param ngramContext the n-gram context + * @param word the word the user inputted + * @param isValid whether the word is valid or not + * @param timestamp the timestamp when the word has been inputted + */ + public static void addToDictionary(final ExpandableBinaryDictionary userHistoryDictionary, + @Nonnull final NgramContext ngramContext, final String word, final boolean isValid, + final int timestamp) { + if (word.length() > BinaryDictionary.DICTIONARY_MAX_WORD_LENGTH) { + return; + } + userHistoryDictionary.updateEntriesForWord(ngramContext, word, + isValid, 1 /* count */, timestamp); + } + + @Override + public void close() { + // Flush pending writes. + asyncFlushBinaryDictionary(); + super.close(); + } + + @Override + protected Map<String, String> getHeaderAttributeMap() { + final Map<String, String> attributeMap = super.getHeaderAttributeMap(); + attributeMap.put(DictionaryHeader.USES_FORGETTING_CURVE_KEY, + DictionaryHeader.ATTRIBUTE_VALUE_TRUE); + attributeMap.put(DictionaryHeader.HAS_HISTORICAL_INFO_KEY, + DictionaryHeader.ATTRIBUTE_VALUE_TRUE); + return attributeMap; + } + + @Override + protected void loadInitialContentsLocked() { + // No initial contents. + } + + @Override + public boolean isValidWord(final String word) { + // Strings out of this dictionary should not be considered existing words. + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/AccountsSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/AccountsSettingsFragment.java new file mode 100644 index 000000000..a361ad32f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/AccountsSettingsFragment.java @@ -0,0 +1,508 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import static org.kelar.inputmethod.latin.settings.LocalSettingsConstants.PREF_ACCOUNT_NAME; +import static org.kelar.inputmethod.latin.settings.LocalSettingsConstants.PREF_ENABLE_CLOUD_SYNC; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnShowListener; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.TwoStatePreference; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.widget.ListView; +import android.widget.TextView; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.accounts.AccountStateChangedListener; +import org.kelar.inputmethod.latin.accounts.LoginAccountUtils; +import org.kelar.inputmethod.latin.define.ProductionFlags; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.utils.ManagedProfileUtils; + +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.annotation.Nullable; + +/** + * "Accounts & Privacy" settings sub screen. + * + * This settings sub screen handles the following preferences: + * <li> Account selection/management for IME </li> + * <li> Sync preferences </li> + * <li> Privacy preferences </li> + */ +public final class AccountsSettingsFragment extends SubScreenFragment { + private static final String PREF_ENABLE_SYNC_NOW = "pref_enable_cloud_sync"; + private static final String PREF_SYNC_NOW = "pref_sync_now"; + private static final String PREF_CLEAR_SYNC_DATA = "pref_clear_sync_data"; + + static final String PREF_ACCCOUNT_SWITCHER = "account_switcher"; + + /** + * Onclick listener for sync now pref. + */ + private final Preference.OnPreferenceClickListener mSyncNowListener = + new SyncNowListener(); + /** + * Onclick listener for delete sync pref. + */ + private final Preference.OnPreferenceClickListener mDeleteSyncDataListener = + new DeleteSyncDataListener(); + + /** + * Onclick listener for enable sync pref. + */ + private final Preference.OnPreferenceClickListener mEnableSyncClickListener = + new EnableSyncClickListener(); + + /** + * Enable sync checkbox pref. + */ + private TwoStatePreference mEnableSyncPreference; + + /** + * Enable sync checkbox pref. + */ + private Preference mSyncNowPreference; + + /** + * Clear sync data pref. + */ + private Preference mClearSyncDataPreference; + + /** + * Account switcher preference. + */ + private Preference mAccountSwitcher; + + /** + * Stores if we are currently detecting a managed profile. + */ + private AtomicBoolean mManagedProfileBeingDetected = new AtomicBoolean(true); + + /** + * Stores if we have successfully detected if the device has a managed profile. + */ + private AtomicBoolean mHasManagedProfile = new AtomicBoolean(false); + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_accounts); + + mAccountSwitcher = findPreference(PREF_ACCCOUNT_SWITCHER); + mEnableSyncPreference = (TwoStatePreference) findPreference(PREF_ENABLE_SYNC_NOW); + mSyncNowPreference = findPreference(PREF_SYNC_NOW); + mClearSyncDataPreference = findPreference(PREF_CLEAR_SYNC_DATA); + + if (ProductionFlags.IS_METRICS_LOGGING_SUPPORTED) { + final Preference enableMetricsLogging = + findPreference(Settings.PREF_ENABLE_METRICS_LOGGING); + final Resources res = getResources(); + if (enableMetricsLogging != null) { + final String enableMetricsLoggingTitle = res.getString( + R.string.enable_metrics_logging, getApplicationName()); + enableMetricsLogging.setTitle(enableMetricsLoggingTitle); + } + } else { + removePreference(Settings.PREF_ENABLE_METRICS_LOGGING); + } + + if (!ProductionFlags.ENABLE_USER_HISTORY_DICTIONARY_SYNC) { + removeSyncPreferences(); + } else { + // Disable by default till we are sure we can enable this. + disableSyncPreferences(); + new ManagedProfileCheckerTask(this).execute(); + } + } + + /** + * Task to check work profile. If found, it removes the sync prefs. If not, + * it enables them. + */ + private static class ManagedProfileCheckerTask extends AsyncTask<Void, Void, Boolean> { + private final AccountsSettingsFragment mFragment; + + private ManagedProfileCheckerTask(final AccountsSettingsFragment fragment) { + mFragment = fragment; + } + + @Override + protected void onPreExecute() { + mFragment.mManagedProfileBeingDetected.set(true); + } + @Override + protected Boolean doInBackground(Void... params) { + return ManagedProfileUtils.getInstance().hasWorkProfile(mFragment.getActivity()); + } + + @Override + protected void onPostExecute(final Boolean hasWorkProfile) { + mFragment.mHasManagedProfile.set(hasWorkProfile); + mFragment.mManagedProfileBeingDetected.set(false); + mFragment.refreshSyncSettingsUI(); + } + } + + private void enableSyncPreferences(final String[] accountsForLogin, + final String currentAccountName) { + if (!ProductionFlags.ENABLE_USER_HISTORY_DICTIONARY_SYNC) { + return; + } + mAccountSwitcher.setEnabled(true); + + mEnableSyncPreference.setEnabled(true); + mEnableSyncPreference.setOnPreferenceClickListener(mEnableSyncClickListener); + + mSyncNowPreference.setEnabled(true); + mSyncNowPreference.setOnPreferenceClickListener(mSyncNowListener); + + mClearSyncDataPreference.setEnabled(true); + mClearSyncDataPreference.setOnPreferenceClickListener(mDeleteSyncDataListener); + + if (currentAccountName != null) { + mAccountSwitcher.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(final Preference preference) { + if (accountsForLogin.length > 0) { + // TODO: Add addition of account. + createAccountPicker(accountsForLogin, getSignedInAccountName(), + new AccountChangedListener(null)).show(); + } + return true; + } + }); + } + } + + /** + * Two reasons for disable - work profile or no accounts on device. + */ + private void disableSyncPreferences() { + if (!ProductionFlags.ENABLE_USER_HISTORY_DICTIONARY_SYNC) { + return; + } + + mAccountSwitcher.setEnabled(false); + mEnableSyncPreference.setEnabled(false); + mSyncNowPreference.setEnabled(false); + mClearSyncDataPreference.setEnabled(false); + } + + /** + * Called only when ProductionFlag is turned off. + */ + private void removeSyncPreferences() { + removePreference(PREF_ACCCOUNT_SWITCHER); + removePreference(PREF_ENABLE_CLOUD_SYNC); + removePreference(PREF_SYNC_NOW); + removePreference(PREF_CLEAR_SYNC_DATA); + } + + @Override + public void onResume() { + super.onResume(); + refreshSyncSettingsUI(); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + if (TextUtils.equals(key, PREF_ACCOUNT_NAME)) { + refreshSyncSettingsUI(); + } else if (TextUtils.equals(key, PREF_ENABLE_CLOUD_SYNC)) { + mEnableSyncPreference = (TwoStatePreference) findPreference(PREF_ENABLE_SYNC_NOW); + final boolean syncEnabled = prefs.getBoolean(PREF_ENABLE_CLOUD_SYNC, false); + if (isSyncEnabled()) { + mEnableSyncPreference.setSummary(getString(R.string.cloud_sync_summary)); + } else { + mEnableSyncPreference.setSummary(getString(R.string.cloud_sync_summary_disabled)); + } + AccountStateChangedListener.onSyncPreferenceChanged(getSignedInAccountName(), + syncEnabled); + } + } + + /** + * Checks different states like whether account is present or managed profile is present + * and sets the sync settings accordingly. + */ + private void refreshSyncSettingsUI() { + if (!ProductionFlags.ENABLE_USER_HISTORY_DICTIONARY_SYNC) { + return; + } + boolean hasAccountsPermission = PermissionsUtil.checkAllPermissionsGranted( + getActivity(), Manifest.permission.READ_CONTACTS); + + final String[] accountsForLogin = hasAccountsPermission ? + LoginAccountUtils.getAccountsForLogin(getActivity()) : new String[0]; + final String currentAccount = hasAccountsPermission ? getSignedInAccountName() : null; + + if (hasAccountsPermission && !mManagedProfileBeingDetected.get() && + !mHasManagedProfile.get() && accountsForLogin.length > 0) { + // Sync can be used by user; enable all preferences. + enableSyncPreferences(accountsForLogin, currentAccount); + } else { + // Sync cannot be used by user; disable all preferences. + disableSyncPreferences(); + } + refreshSyncSettingsMessaging(hasAccountsPermission, mManagedProfileBeingDetected.get(), + mHasManagedProfile.get(), accountsForLogin.length > 0, + currentAccount); + } + + /** + * @param hasAccountsPermission whether the app has the permission to read accounts. + * @param managedProfileBeingDetected whether we are in process of determining work profile. + * @param hasManagedProfile whether the device has work profile. + * @param hasAccountsForLogin whether the device has enough accounts for login. + * @param currentAccount the account currently selected in the application. + */ + private void refreshSyncSettingsMessaging(boolean hasAccountsPermission, + boolean managedProfileBeingDetected, + boolean hasManagedProfile, + boolean hasAccountsForLogin, + String currentAccount) { + if (!ProductionFlags.ENABLE_USER_HISTORY_DICTIONARY_SYNC) { + return; + } + + if (!hasAccountsPermission) { + mEnableSyncPreference.setChecked(false); + mEnableSyncPreference.setSummary(getString(R.string.cloud_sync_summary_disabled)); + mAccountSwitcher.setSummary(""); + return; + } else if (managedProfileBeingDetected) { + // If we are determining eligiblity, we show empty summaries. + // Once we have some deterministic result, we set summaries based on different results. + mEnableSyncPreference.setSummary(""); + mAccountSwitcher.setSummary(""); + } else if (hasManagedProfile) { + mEnableSyncPreference.setSummary( + getString(R.string.cloud_sync_summary_disabled_work_profile)); + } else if (!hasAccountsForLogin) { + mEnableSyncPreference.setSummary(getString(R.string.add_account_to_enable_sync)); + } else if (isSyncEnabled()) { + mEnableSyncPreference.setSummary(getString(R.string.cloud_sync_summary)); + } else { + mEnableSyncPreference.setSummary(getString(R.string.cloud_sync_summary_disabled)); + } + + // Set some interdependent settings. + // No account automatically turns off sync. + if (!managedProfileBeingDetected && !hasManagedProfile) { + if (currentAccount != null) { + mAccountSwitcher.setSummary(getString(R.string.account_selected, currentAccount)); + } else { + mEnableSyncPreference.setChecked(false); + mAccountSwitcher.setSummary(getString(R.string.no_accounts_selected)); + } + } + } + + @Nullable + String getSignedInAccountName() { + return getSharedPreferences().getString(LocalSettingsConstants.PREF_ACCOUNT_NAME, null); + } + + boolean isSyncEnabled() { + return getSharedPreferences().getBoolean(PREF_ENABLE_CLOUD_SYNC, false); + } + + /** + * Creates an account picker dialog showing the given accounts in a list and selecting + * the selected account by default. The list of accounts must not be null/empty. + * + * Package-private for testing. + * + * @param accounts list of accounts on the device. + * @param selectedAccount currently selected account + * @param positiveButtonClickListener listener that gets called when positive button is + * clicked + */ + @UsedForTesting + AlertDialog createAccountPicker(final String[] accounts, + final String selectedAccount, + final DialogInterface.OnClickListener positiveButtonClickListener) { + if (accounts == null || accounts.length == 0) { + throw new IllegalArgumentException("List of accounts must not be empty"); + } + + // See if the currently selected account is in the list. + // If it is, the entry is selected, and a sign-out button is provided. + // If it isn't, select the 0th account by default which will get picked up + // if the user presses OK. + int index = 0; + boolean isSignedIn = false; + for (int i = 0; i < accounts.length; i++) { + if (TextUtils.equals(accounts[i], selectedAccount)) { + index = i; + isSignedIn = true; + break; + } + } + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.account_select_title) + .setSingleChoiceItems(accounts, index, null) + .setPositiveButton(R.string.account_select_ok, positiveButtonClickListener) + .setNegativeButton(R.string.account_select_cancel, null); + if (isSignedIn) { + builder.setNeutralButton(R.string.account_select_sign_out, positiveButtonClickListener); + } + return builder.create(); + } + + /** + * Listener for a account selection changes from the picker. + * Persists/removes the account to/from shared preferences and sets up sync if required. + */ + class AccountChangedListener implements DialogInterface.OnClickListener { + /** + * Represents preference that should be changed based on account chosen. + */ + private TwoStatePreference mDependentPreference; + + AccountChangedListener(final TwoStatePreference dependentPreference) { + mDependentPreference = dependentPreference; + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + final String oldAccount = getSignedInAccountName(); + switch (which) { + case DialogInterface.BUTTON_POSITIVE: // Signed in + final ListView lv = ((AlertDialog)dialog).getListView(); + final String newAccount = + (String) lv.getItemAtPosition(lv.getCheckedItemPosition()); + getSharedPreferences() + .edit() + .putString(PREF_ACCOUNT_NAME, newAccount) + .apply(); + AccountStateChangedListener.onAccountSignedIn(oldAccount, newAccount); + if (mDependentPreference != null) { + mDependentPreference.setChecked(true); + } + break; + case DialogInterface.BUTTON_NEUTRAL: // Signed out + AccountStateChangedListener.onAccountSignedOut(oldAccount); + getSharedPreferences() + .edit() + .remove(PREF_ACCOUNT_NAME) + .apply(); + break; + } + } + } + + /** + * Listener that initiates the process of sync in the background. + */ + class SyncNowListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(final Preference preference) { + AccountStateChangedListener.forceSync(getSignedInAccountName()); + return true; + } + } + + /** + * Listener that initiates the process of deleting user's data from the cloud. + */ + class DeleteSyncDataListener implements Preference.OnPreferenceClickListener { + @Override + public boolean onPreferenceClick(final Preference preference) { + final AlertDialog confirmationDialog = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.clear_sync_data_title) + .setMessage(R.string.clear_sync_data_confirmation) + .setPositiveButton(R.string.clear_sync_data_ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + AccountStateChangedListener.forceDelete( + getSignedInAccountName()); + } + } + }) + .setNegativeButton(R.string.cloud_sync_cancel, null /* OnClickListener */) + .create(); + confirmationDialog.show(); + return true; + } + } + + /** + * Listens to events when user clicks on "Enable sync" feature. + */ + class EnableSyncClickListener implements OnShowListener, Preference.OnPreferenceClickListener { + // TODO(cvnguyen): Write tests. + @Override + public boolean onPreferenceClick(final Preference preference) { + final TwoStatePreference syncPreference = (TwoStatePreference) preference; + if (syncPreference.isChecked()) { + // Uncheck for now. + syncPreference.setChecked(false); + + // Show opt-in. + final AlertDialog optInDialog = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.cloud_sync_title) + .setMessage(R.string.cloud_sync_opt_in_text) + .setPositiveButton(R.string.account_select_ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + final Context context = getActivity(); + final String[] accountsForLogin = + LoginAccountUtils.getAccountsForLogin(context); + createAccountPicker(accountsForLogin, + getSignedInAccountName(), + new AccountChangedListener(syncPreference)) + .show(); + } + } + }) + .setNegativeButton(R.string.cloud_sync_cancel, null) + .create(); + optInDialog.setOnShowListener(this); + optInDialog.show(); + } + return true; + } + + @Override + public void onShow(DialogInterface dialog) { + TextView messageView = (TextView) ((AlertDialog) dialog).findViewById( + android.R.id.message); + if (messageView != null) { + messageView.setMovementMethod(LinkMovementMethod.getInstance()); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/AdditionalFeaturesSettingUtils.java b/java/src/org/kelar/inputmethod/latin/settings/AdditionalFeaturesSettingUtils.java new file mode 100644 index 000000000..95e589c75 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/AdditionalFeaturesSettingUtils.java @@ -0,0 +1,57 @@ +/* + * 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceFragment; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.RichInputMethodManager; + +import javax.annotation.Nonnull; + +/** + * Utility class for managing additional features settings. + */ +@SuppressWarnings("unused") +public class AdditionalFeaturesSettingUtils { + public static final int ADDITIONAL_FEATURES_SETTINGS_SIZE = 0; + + private AdditionalFeaturesSettingUtils() { + // This utility class is not publicly instantiable. + } + + public static void addAdditionalFeaturesPreferences( + final Context context, final PreferenceFragment settingsFragment) { + // do nothing. + } + + public static void readAdditionalFeaturesPreferencesIntoArray(final Context context, + final SharedPreferences prefs, final int[] additionalFeaturesPreferences) { + // do nothing. + } + + @Nonnull + public static RichInputMethodSubtype createRichInputMethodSubtype( + @Nonnull final RichInputMethodManager imm, + @Nonnull final InputMethodSubtype subtype, + final Context context) { + return new RichInputMethodSubtype(subtype); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/AdvancedSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/AdvancedSettingsFragment.java new file mode 100644 index 000000000..9f3df399e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/AdvancedSettingsFragment.java @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.media.AudioManager; +import android.os.Bundle; +import android.preference.ListPreference; + +import org.kelar.inputmethod.latin.AudioAndHapticFeedbackManager; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SystemBroadcastReceiver; + +/** + * "Advanced" settings sub screen. + * + * This settings sub screen handles the following advanced preferences. + * - Key popup dismiss delay + * - Keypress vibration duration + * - Keypress sound volume + * - Show app icon + * - Improve keyboard + * - Debug settings + */ +public final class AdvancedSettingsFragment extends SubScreenFragment { + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_advanced); + + final Resources res = getResources(); + final Context context = getActivity(); + + // When we are called from the Settings application but we are not already running, some + // singleton and utility classes may not have been initialized. We have to call + // initialization method of these classes here. See {@link LatinIME#onCreate()}. + AudioAndHapticFeedbackManager.init(context); + + final SharedPreferences prefs = getPreferenceManager().getSharedPreferences(); + + if (!Settings.isInternal(prefs)) { + removePreference(Settings.SCREEN_DEBUG); + } + + if (!AudioAndHapticFeedbackManager.getInstance().hasVibrator()) { + removePreference(Settings.PREF_VIBRATION_DURATION_SETTINGS); + } + + // TODO: consolidate key preview dismiss delay with the key preview animation parameters. + if (!Settings.readFromBuildConfigIfToShowKeyPreviewPopupOption(res)) { + removePreference(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY); + } else { + // TODO: Cleanup this setup. + final ListPreference keyPreviewPopupDismissDelay = + (ListPreference) findPreference(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY); + final String popupDismissDelayDefaultValue = Integer.toString(res.getInteger( + R.integer.config_key_preview_linger_timeout)); + keyPreviewPopupDismissDelay.setEntries(new String[] { + res.getString(R.string.key_preview_popup_dismiss_no_delay), + res.getString(R.string.key_preview_popup_dismiss_default_delay), + }); + keyPreviewPopupDismissDelay.setEntryValues(new String[] { + "0", + popupDismissDelayDefaultValue + }); + if (null == keyPreviewPopupDismissDelay.getValue()) { + keyPreviewPopupDismissDelay.setValue(popupDismissDelayDefaultValue); + } + keyPreviewPopupDismissDelay.setEnabled( + Settings.readKeyPreviewPopupEnabled(prefs, res)); + } + + setupKeypressVibrationDurationSettings(); + setupKeypressSoundVolumeSettings(); + setupKeyLongpressTimeoutSettings(); + refreshEnablingsOfKeypressSoundAndVibrationSettings(); + } + + @Override + public void onResume() { + super.onResume(); + final SharedPreferences prefs = getPreferenceManager().getSharedPreferences(); + updateListPreferenceSummaryToCurrentValue(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + final Resources res = getResources(); + if (key.equals(Settings.PREF_POPUP_ON)) { + setPreferenceEnabled(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY, + Settings.readKeyPreviewPopupEnabled(prefs, res)); + } else if (key.equals(Settings.PREF_SHOW_SETUP_WIZARD_ICON)) { + SystemBroadcastReceiver.toggleAppIcon(getActivity()); + } + updateListPreferenceSummaryToCurrentValue(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY); + refreshEnablingsOfKeypressSoundAndVibrationSettings(); + } + + private void refreshEnablingsOfKeypressSoundAndVibrationSettings() { + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + setPreferenceEnabled(Settings.PREF_VIBRATION_DURATION_SETTINGS, + Settings.readVibrationEnabled(prefs, res)); + setPreferenceEnabled(Settings.PREF_KEYPRESS_SOUND_VOLUME, + Settings.readKeypressSoundEnabled(prefs, res)); + } + + private void setupKeypressVibrationDurationSettings() { + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference( + Settings.PREF_VIBRATION_DURATION_SETTINGS); + if (pref == null) { + return; + } + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putInt(key, value).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return Settings.readKeypressVibrationDuration(prefs, res); + } + + @Override + public int readDefaultValue(final String key) { + return Settings.readDefaultKeypressVibrationDuration(res); + } + + @Override + public void feedbackValue(final int value) { + AudioAndHapticFeedbackManager.getInstance().vibrate(value); + } + + @Override + public String getValueText(final int value) { + if (value < 0) { + return res.getString(R.string.settings_system_default); + } + return res.getString(R.string.abbreviation_unit_milliseconds, value); + } + }); + } + + private void setupKeypressSoundVolumeSettings() { + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference( + Settings.PREF_KEYPRESS_SOUND_VOLUME); + if (pref == null) { + return; + } + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + final AudioManager am = (AudioManager)getActivity().getSystemService(Context.AUDIO_SERVICE); + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + private static final float PERCENTAGE_FLOAT = 100.0f; + + private float getValueFromPercentage(final int percentage) { + return percentage / PERCENTAGE_FLOAT; + } + + private int getPercentageFromValue(final float floatValue) { + return (int)(floatValue * PERCENTAGE_FLOAT); + } + + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putFloat(key, getValueFromPercentage(value)).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return getPercentageFromValue(Settings.readKeypressSoundVolume(prefs, res)); + } + + @Override + public int readDefaultValue(final String key) { + return getPercentageFromValue(Settings.readDefaultKeypressSoundVolume(res)); + } + + @Override + public String getValueText(final int value) { + if (value < 0) { + return res.getString(R.string.settings_system_default); + } + return Integer.toString(value); + } + + @Override + public void feedbackValue(final int value) { + am.playSoundEffect( + AudioManager.FX_KEYPRESS_STANDARD, getValueFromPercentage(value)); + } + }); + } + + private void setupKeyLongpressTimeoutSettings() { + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference( + Settings.PREF_KEY_LONGPRESS_TIMEOUT); + if (pref == null) { + return; + } + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putInt(key, value).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return Settings.readKeyLongpressTimeout(prefs, res); + } + + @Override + public int readDefaultValue(final String key) { + return Settings.readDefaultKeyLongpressTimeout(res); + } + + @Override + public String getValueText(final int value) { + return res.getString(R.string.abbreviation_unit_milliseconds, value); + } + + @Override + public void feedbackValue(final int value) {} + }); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/AppearanceSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/AppearanceSettingsFragment.java new file mode 100644 index 000000000..a294f1a6d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/AppearanceSettingsFragment.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.os.Bundle; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.define.ProductionFlags; + +/** + * "Appearance" settings sub screen. + */ +public final class AppearanceSettingsFragment extends SubScreenFragment { + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_appearance); + if (!ProductionFlags.IS_SPLIT_KEYBOARD_SUPPORTED || + Constants.isPhone(Settings.readScreenMetrics(getResources()))) { + removePreference(Settings.PREF_ENABLE_SPLIT_KEYBOARD); + } + } + + @Override + public void onResume() { + super.onResume(); + CustomInputStyleSettingsFragment.updateCustomInputStylesSummary( + findPreference(Settings.PREF_CUSTOM_INPUT_STYLES)); + ThemeSettingsFragment.updateKeyboardThemeSummary(findPreference(Settings.SCREEN_THEME)); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/CorrectionSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/CorrectionSettingsFragment.java new file mode 100644 index 000000000..0594ce5d1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/CorrectionSettingsFragment.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.SwitchPreference; +import android.text.TextUtils; + +import org.kelar.inputmethod.dictionarypack.DictionarySettingsActivity; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.permissions.PermissionsManager; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryList; +import org.kelar.inputmethod.latin.userdictionary.UserDictionarySettings; + +import java.util.TreeSet; + +/** + * "Text correction" settings sub screen. + * + * This settings sub screen handles the following text correction preferences. + * - Personal dictionary + * - Add-on dictionaries + * - Block offensive words + * - Auto-correction + * - Show correction suggestions + * - Personalized suggestions + * - Suggest Contact names + * - Next-word suggestions + */ +public final class CorrectionSettingsFragment extends SubScreenFragment + implements SharedPreferences.OnSharedPreferenceChangeListener, + PermissionsManager.PermissionsResultCallback { + + private static final boolean DBG_USE_INTERNAL_PERSONAL_DICTIONARY_SETTINGS = false; + private static final boolean USE_INTERNAL_PERSONAL_DICTIONARY_SETTINGS = + DBG_USE_INTERNAL_PERSONAL_DICTIONARY_SETTINGS + || Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2; + + private SwitchPreference mUseContactsPreference; + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_correction); + + final Context context = getActivity(); + final PackageManager pm = context.getPackageManager(); + + final Preference dictionaryLink = findPreference(Settings.PREF_CONFIGURE_DICTIONARIES_KEY); + final Intent intent = dictionaryLink.getIntent(); + intent.setClassName(context.getPackageName(), DictionarySettingsActivity.class.getName()); + final int number = pm.queryIntentActivities(intent, 0).size(); + if (0 >= number) { + removePreference(Settings.PREF_CONFIGURE_DICTIONARIES_KEY); + } + + final Preference editPersonalDictionary = + findPreference(Settings.PREF_EDIT_PERSONAL_DICTIONARY); + final Intent editPersonalDictionaryIntent = editPersonalDictionary.getIntent(); + final ResolveInfo ri = USE_INTERNAL_PERSONAL_DICTIONARY_SETTINGS ? null + : pm.resolveActivity( + editPersonalDictionaryIntent, PackageManager.MATCH_DEFAULT_ONLY); + if (ri == null) { + overwriteUserDictionaryPreference(editPersonalDictionary); + } + + mUseContactsPreference = (SwitchPreference) findPreference(Settings.PREF_KEY_USE_CONTACTS_DICT); + turnOffUseContactsIfNoPermission(); + } + + private void overwriteUserDictionaryPreference(final Preference userDictionaryPreference) { + final Activity activity = getActivity(); + final TreeSet<String> localeList = UserDictionaryList.getUserDictionaryLocalesSet(activity); + if (null == localeList) { + // The locale list is null if and only if the user dictionary service is + // not present or disabled. In this case we need to remove the preference. + getPreferenceScreen().removePreference(userDictionaryPreference); + } else if (localeList.size() <= 1) { + userDictionaryPreference.setFragment(UserDictionarySettings.class.getName()); + // If the size of localeList is 0, we don't set the locale parameter in the + // extras. This will be interpreted by the UserDictionarySettings class as + // meaning "the current locale". + // Note that with the current code for UserDictionaryList#getUserDictionaryLocalesSet() + // the locale list always has at least one element, since it always includes the current + // locale explicitly. @see UserDictionaryList.getUserDictionaryLocalesSet(). + if (localeList.size() == 1) { + final String locale = (String)localeList.toArray()[0]; + userDictionaryPreference.getExtras().putString("locale", locale); + } + } else { + userDictionaryPreference.setFragment(UserDictionaryList.class.getName()); + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { + if (!TextUtils.equals(key, Settings.PREF_KEY_USE_CONTACTS_DICT)) { + return; + } + if (!sharedPreferences.getBoolean(key, false)) { + // don't care if the preference is turned off. + return; + } + + // Check for permissions. + if (PermissionsUtil.checkAllPermissionsGranted( + getActivity() /* context */, Manifest.permission.READ_CONTACTS)) { + return; // all permissions granted, no need to request permissions. + } + + PermissionsManager.get(getActivity() /* context */).requestPermissions( + this /* PermissionsResultCallback */, + getActivity() /* activity */, + Manifest.permission.READ_CONTACTS); + } + + @Override + public void onRequestPermissionsResult(boolean allGranted) { + turnOffUseContactsIfNoPermission(); + } + + private void turnOffUseContactsIfNoPermission() { + if (!PermissionsUtil.checkAllPermissionsGranted( + getActivity(), Manifest.permission.READ_CONTACTS)) { + mUseContactsPreference.setChecked(false); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/CustomInputStylePreference.java b/java/src/org/kelar/inputmethod/latin/settings/CustomInputStylePreference.java new file mode 100644 index 000000000..0f4cd0da3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/CustomInputStylePreference.java @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Parcel; +import android.os.Parcelable; +import android.preference.DialogPreference; +import android.preference.Preference; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodSubtype; +import android.widget.ArrayAdapter; +import android.widget.Spinner; +import android.widget.SpinnerAdapter; + +import org.kelar.inputmethod.compat.InputMethodSubtypeCompatUtils; +import org.kelar.inputmethod.compat.ViewCompatUtils; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.util.TreeSet; + +final class CustomInputStylePreference extends DialogPreference + implements DialogInterface.OnCancelListener { + private static final boolean DEBUG_SUBTYPE_ID = false; + + interface Listener { + public void onRemoveCustomInputStyle(CustomInputStylePreference stylePref); + public void onSaveCustomInputStyle(CustomInputStylePreference stylePref); + public void onAddCustomInputStyle(CustomInputStylePreference stylePref); + public SubtypeLocaleAdapter getSubtypeLocaleAdapter(); + public KeyboardLayoutSetAdapter getKeyboardLayoutSetAdapter(); + } + + private static final String KEY_PREFIX = "subtype_pref_"; + private static final String KEY_NEW_SUBTYPE = KEY_PREFIX + "new"; + + private InputMethodSubtype mSubtype; + private InputMethodSubtype mPreviousSubtype; + + private final Listener mProxy; + private Spinner mSubtypeLocaleSpinner; + private Spinner mKeyboardLayoutSetSpinner; + + public static CustomInputStylePreference newIncompleteSubtypePreference( + final Context context, final Listener proxy) { + return new CustomInputStylePreference(context, null, proxy); + } + + public CustomInputStylePreference(final Context context, final InputMethodSubtype subtype, + final Listener proxy) { + super(context, null); + setDialogLayoutResource(R.layout.additional_subtype_dialog); + setPersistent(false); + mProxy = proxy; + setSubtype(subtype); + } + + public void show() { + showDialog(null); + } + + public final boolean isIncomplete() { + return mSubtype == null; + } + + public InputMethodSubtype getSubtype() { + return mSubtype; + } + + public void setSubtype(final InputMethodSubtype subtype) { + mPreviousSubtype = mSubtype; + mSubtype = subtype; + if (isIncomplete()) { + setTitle(null); + setDialogTitle(R.string.add_style); + setKey(KEY_NEW_SUBTYPE); + } else { + final String displayName = + SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype); + setTitle(displayName); + setDialogTitle(displayName); + setKey(KEY_PREFIX + subtype.getLocale() + "_" + + SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype)); + } + } + + public void revert() { + setSubtype(mPreviousSubtype); + } + + public boolean hasBeenModified() { + return mSubtype != null && !mSubtype.equals(mPreviousSubtype); + } + + @Override + protected View onCreateDialogView() { + final View v = super.onCreateDialogView(); + mSubtypeLocaleSpinner = (Spinner) v.findViewById(R.id.subtype_locale_spinner); + mSubtypeLocaleSpinner.setAdapter(mProxy.getSubtypeLocaleAdapter()); + mKeyboardLayoutSetSpinner = (Spinner) v.findViewById(R.id.keyboard_layout_set_spinner); + mKeyboardLayoutSetSpinner.setAdapter(mProxy.getKeyboardLayoutSetAdapter()); + // All keyboard layout names are in the Latin script and thus left to right. That means + // the view would align them to the left even if the system locale is RTL, but that + // would look strange. To fix this, we align them to the view's start, which will be + // natural for any direction. + ViewCompatUtils.setTextAlignment( + mKeyboardLayoutSetSpinner, ViewCompatUtils.TEXT_ALIGNMENT_VIEW_START); + return v; + } + + @Override + protected void onPrepareDialogBuilder(final AlertDialog.Builder builder) { + builder.setCancelable(true).setOnCancelListener(this); + if (isIncomplete()) { + builder.setPositiveButton(R.string.add, this) + .setNegativeButton(android.R.string.cancel, this); + } else { + builder.setPositiveButton(R.string.save, this) + .setNeutralButton(android.R.string.cancel, this) + .setNegativeButton(R.string.remove, this); + final SubtypeLocaleItem localeItem = new SubtypeLocaleItem(mSubtype); + final KeyboardLayoutSetItem layoutItem = new KeyboardLayoutSetItem(mSubtype); + setSpinnerPosition(mSubtypeLocaleSpinner, localeItem); + setSpinnerPosition(mKeyboardLayoutSetSpinner, layoutItem); + } + } + + private static void setSpinnerPosition(final Spinner spinner, final Object itemToSelect) { + final SpinnerAdapter adapter = spinner.getAdapter(); + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + final Object item = spinner.getItemAtPosition(i); + if (item.equals(itemToSelect)) { + spinner.setSelection(i); + return; + } + } + } + + @Override + public void onCancel(final DialogInterface dialog) { + if (isIncomplete()) { + mProxy.onRemoveCustomInputStyle(this); + } + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + super.onClick(dialog, which); + switch (which) { + case DialogInterface.BUTTON_POSITIVE: + final boolean isEditing = !isIncomplete(); + final SubtypeLocaleItem locale = + (SubtypeLocaleItem) mSubtypeLocaleSpinner.getSelectedItem(); + final KeyboardLayoutSetItem layout = + (KeyboardLayoutSetItem) mKeyboardLayoutSetSpinner.getSelectedItem(); + final InputMethodSubtype subtype = + AdditionalSubtypeUtils.createAsciiEmojiCapableAdditionalSubtype( + locale.mLocaleString, layout.mLayoutName); + setSubtype(subtype); + notifyChanged(); + if (isEditing) { + mProxy.onSaveCustomInputStyle(this); + } else { + mProxy.onAddCustomInputStyle(this); + } + break; + case DialogInterface.BUTTON_NEUTRAL: + // Nothing to do + break; + case DialogInterface.BUTTON_NEGATIVE: + mProxy.onRemoveCustomInputStyle(this); + break; + } + } + + @Override + protected Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final Dialog dialog = getDialog(); + if (dialog == null || !dialog.isShowing()) { + return superState; + } + + final SavedState myState = new SavedState(superState); + myState.mSubtype = mSubtype; + return myState; + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState myState = (SavedState) state; + super.onRestoreInstanceState(myState.getSuperState()); + setSubtype(myState.mSubtype); + } + + static final class SavedState extends Preference.BaseSavedState { + InputMethodSubtype mSubtype; + + public SavedState(final Parcelable superState) { + super(superState); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + super.writeToParcel(dest, flags); + dest.writeParcelable(mSubtype, 0); + } + + public SavedState(final Parcel source) { + super(source); + mSubtype = (InputMethodSubtype)source.readParcelable(null); + } + + @SuppressWarnings("hiding") + public static final Parcelable.Creator<SavedState> CREATOR = + new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(final Parcel source) { + return new SavedState(source); + } + + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } + + static final class SubtypeLocaleItem implements Comparable<SubtypeLocaleItem> { + public final String mLocaleString; + private final String mDisplayName; + + public SubtypeLocaleItem(final InputMethodSubtype subtype) { + mLocaleString = subtype.getLocale(); + mDisplayName = SubtypeLocaleUtils.getSubtypeLocaleDisplayNameInSystemLocale( + mLocaleString); + } + + // {@link ArrayAdapter<T>} that hosts the instance of this class needs {@link #toString()} + // to get display name. + @Override + public String toString() { + return mDisplayName; + } + + @Override + public int compareTo(final SubtypeLocaleItem o) { + return mLocaleString.compareTo(o.mLocaleString); + } + } + + static final class SubtypeLocaleAdapter extends ArrayAdapter<SubtypeLocaleItem> { + private static final String TAG_SUBTYPE = SubtypeLocaleAdapter.class.getSimpleName(); + + public SubtypeLocaleAdapter(final Context context) { + super(context, android.R.layout.simple_spinner_item); + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + final TreeSet<SubtypeLocaleItem> items = new TreeSet<>(); + final InputMethodInfo imi = RichInputMethodManager.getInstance() + .getInputMethodInfoOfThisIme(); + final int count = imi.getSubtypeCount(); + for (int i = 0; i < count; i++) { + final InputMethodSubtype subtype = imi.getSubtypeAt(i); + if (DEBUG_SUBTYPE_ID) { + Log.d(TAG_SUBTYPE, String.format("%-6s 0x%08x %11d %s", + subtype.getLocale(), subtype.hashCode(), subtype.hashCode(), + SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype))); + } + if (InputMethodSubtypeCompatUtils.isAsciiCapable(subtype)) { + items.add(new SubtypeLocaleItem(subtype)); + } + } + // TODO: Should filter out already existing combinations of locale and layout. + addAll(items); + } + } + + static final class KeyboardLayoutSetItem { + public final String mLayoutName; + private final String mDisplayName; + + public KeyboardLayoutSetItem(final InputMethodSubtype subtype) { + mLayoutName = SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype); + mDisplayName = SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(subtype); + } + + // {@link ArrayAdapter<T>} that hosts the instance of this class needs {@link #toString()} + // to get display name. + @Override + public String toString() { + return mDisplayName; + } + } + + static final class KeyboardLayoutSetAdapter extends ArrayAdapter<KeyboardLayoutSetItem> { + public KeyboardLayoutSetAdapter(final Context context) { + super(context, android.R.layout.simple_spinner_item); + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + final String[] predefinedKeyboardLayoutSet = context.getResources().getStringArray( + R.array.predefined_layouts); + // TODO: Should filter out already existing combinations of locale and layout. + for (final String layout : predefinedKeyboardLayoutSet) { + // This is a placeholder for a subtype with NO_LANGUAGE, only for display. + final InputMethodSubtype subtype = + AdditionalSubtypeUtils.createDummyAdditionalSubtype( + SubtypeLocaleUtils.NO_LANGUAGE, layout); + add(new KeyboardLayoutSetItem(subtype)); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/CustomInputStyleSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/CustomInputStyleSettingsFragment.java new file mode 100644 index 000000000..2e83719f2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/CustomInputStyleSettingsFragment.java @@ -0,0 +1,318 @@ +/* + * 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 org.kelar.inputmethod.latin.settings; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import androidx.core.view.ViewCompat; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodSubtype; +import android.widget.Toast; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils; +import org.kelar.inputmethod.latin.utils.DialogUtils; +import org.kelar.inputmethod.latin.utils.IntentUtils; +import org.kelar.inputmethod.latin.utils.SubtypeLocaleUtils; + +import java.util.ArrayList; + +public final class CustomInputStyleSettingsFragment extends PreferenceFragment + implements CustomInputStylePreference.Listener { + private static final String TAG = CustomInputStyleSettingsFragment.class.getSimpleName(); + // Note: We would like to turn this debug flag true in order to see what input styles are + // defined in a bug-report. + private static final boolean DEBUG_CUSTOM_INPUT_STYLES = true; + + private RichInputMethodManager mRichImm; + private SharedPreferences mPrefs; + private CustomInputStylePreference.SubtypeLocaleAdapter mSubtypeLocaleAdapter; + private CustomInputStylePreference.KeyboardLayoutSetAdapter mKeyboardLayoutSetAdapter; + + private boolean mIsAddingNewSubtype; + private AlertDialog mSubtypeEnablerNotificationDialog; + private String mSubtypePreferenceKeyForSubtypeEnabler; + + private static final String KEY_IS_ADDING_NEW_SUBTYPE = "is_adding_new_subtype"; + private static final String KEY_IS_SUBTYPE_ENABLER_NOTIFICATION_DIALOG_OPEN = + "is_subtype_enabler_notification_dialog_open"; + private static final String KEY_SUBTYPE_FOR_SUBTYPE_ENABLER = "subtype_for_subtype_enabler"; + + public CustomInputStyleSettingsFragment() { + // Empty constructor for fragment generation. + } + + static void updateCustomInputStylesSummary(final Preference pref) { + // When we are called from the Settings application but we are not already running, some + // singleton and utility classes may not have been initialized. We have to call + // initialization method of these classes here. See {@link LatinIME#onCreate()}. + SubtypeLocaleUtils.init(pref.getContext()); + + final Resources res = pref.getContext().getResources(); + final SharedPreferences prefs = pref.getSharedPreferences(); + final String prefSubtype = Settings.readPrefAdditionalSubtypes(prefs, res); + final InputMethodSubtype[] subtypes = + AdditionalSubtypeUtils.createAdditionalSubtypesArray(prefSubtype); + final ArrayList<String> subtypeNames = new ArrayList<>(); + for (final InputMethodSubtype subtype : subtypes) { + subtypeNames.add(SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype)); + } + // TODO: A delimiter of custom input styles should be localized. + pref.setSummary(TextUtils.join(", ", subtypeNames)); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mPrefs = getPreferenceManager().getSharedPreferences(); + RichInputMethodManager.init(getActivity()); + mRichImm = RichInputMethodManager.getInstance(); + addPreferencesFromResource(R.xml.additional_subtype_settings); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = super.onCreateView(inflater, container, savedInstanceState); + // For correct display in RTL locales, we need to set the layout direction of the + // fragment's top view. + ViewCompat.setLayoutDirection(view, ViewCompat.LAYOUT_DIRECTION_LOCALE); + return view; + } + + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + final Context context = getActivity(); + mSubtypeLocaleAdapter = new CustomInputStylePreference.SubtypeLocaleAdapter(context); + mKeyboardLayoutSetAdapter = + new CustomInputStylePreference.KeyboardLayoutSetAdapter(context); + + final String prefSubtypes = + Settings.readPrefAdditionalSubtypes(mPrefs, getResources()); + if (DEBUG_CUSTOM_INPUT_STYLES) { + Log.i(TAG, "Load custom input styles: " + prefSubtypes); + } + setPrefSubtypes(prefSubtypes, context); + + mIsAddingNewSubtype = (savedInstanceState != null) + && savedInstanceState.containsKey(KEY_IS_ADDING_NEW_SUBTYPE); + if (mIsAddingNewSubtype) { + getPreferenceScreen().addPreference( + CustomInputStylePreference.newIncompleteSubtypePreference(context, this)); + } + + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null && savedInstanceState.containsKey( + KEY_IS_SUBTYPE_ENABLER_NOTIFICATION_DIALOG_OPEN)) { + mSubtypePreferenceKeyForSubtypeEnabler = savedInstanceState.getString( + KEY_SUBTYPE_FOR_SUBTYPE_ENABLER); + mSubtypeEnablerNotificationDialog = createDialog(); + mSubtypeEnablerNotificationDialog.show(); + } + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mIsAddingNewSubtype) { + outState.putBoolean(KEY_IS_ADDING_NEW_SUBTYPE, true); + } + if (mSubtypeEnablerNotificationDialog != null + && mSubtypeEnablerNotificationDialog.isShowing()) { + outState.putBoolean(KEY_IS_SUBTYPE_ENABLER_NOTIFICATION_DIALOG_OPEN, true); + outState.putString( + KEY_SUBTYPE_FOR_SUBTYPE_ENABLER, mSubtypePreferenceKeyForSubtypeEnabler); + } + } + + @Override + public void onRemoveCustomInputStyle(final CustomInputStylePreference stylePref) { + mIsAddingNewSubtype = false; + final PreferenceGroup group = getPreferenceScreen(); + group.removePreference(stylePref); + mRichImm.setAdditionalInputMethodSubtypes(getSubtypes()); + } + + @Override + public void onSaveCustomInputStyle(final CustomInputStylePreference stylePref) { + final InputMethodSubtype subtype = stylePref.getSubtype(); + if (!stylePref.hasBeenModified()) { + return; + } + if (findDuplicatedSubtype(subtype) == null) { + mRichImm.setAdditionalInputMethodSubtypes(getSubtypes()); + return; + } + + // Saved subtype is duplicated. + final PreferenceGroup group = getPreferenceScreen(); + group.removePreference(stylePref); + stylePref.revert(); + group.addPreference(stylePref); + showSubtypeAlreadyExistsToast(subtype); + } + + @Override + public void onAddCustomInputStyle(final CustomInputStylePreference stylePref) { + mIsAddingNewSubtype = false; + final InputMethodSubtype subtype = stylePref.getSubtype(); + if (findDuplicatedSubtype(subtype) == null) { + mRichImm.setAdditionalInputMethodSubtypes(getSubtypes()); + mSubtypePreferenceKeyForSubtypeEnabler = stylePref.getKey(); + mSubtypeEnablerNotificationDialog = createDialog(); + mSubtypeEnablerNotificationDialog.show(); + return; + } + + // Newly added subtype is duplicated. + final PreferenceGroup group = getPreferenceScreen(); + group.removePreference(stylePref); + showSubtypeAlreadyExistsToast(subtype); + } + + @Override + public CustomInputStylePreference.SubtypeLocaleAdapter getSubtypeLocaleAdapter() { + return mSubtypeLocaleAdapter; + } + + @Override + public CustomInputStylePreference.KeyboardLayoutSetAdapter getKeyboardLayoutSetAdapter() { + return mKeyboardLayoutSetAdapter; + } + + private void showSubtypeAlreadyExistsToast(final InputMethodSubtype subtype) { + final Context context = getActivity(); + final Resources res = context.getResources(); + final String message = res.getString(R.string.custom_input_style_already_exists, + SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype)); + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + } + + private InputMethodSubtype findDuplicatedSubtype(final InputMethodSubtype subtype) { + final String localeString = subtype.getLocale(); + final String keyboardLayoutSetName = SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype); + return mRichImm.findSubtypeByLocaleAndKeyboardLayoutSet( + localeString, keyboardLayoutSetName); + } + + private AlertDialog createDialog() { + final String imeId = mRichImm.getInputMethodIdOfThisIme(); + final AlertDialog.Builder builder = new AlertDialog.Builder( + DialogUtils.getPlatformDialogThemeContext(getActivity())); + builder.setTitle(R.string.custom_input_styles_title) + .setMessage(R.string.custom_input_style_note_message) + .setNegativeButton(R.string.not_now, null) + .setPositiveButton(R.string.enable, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final Intent intent = IntentUtils.getInputLanguageSelectionIntent( + imeId, + Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + // TODO: Add newly adding subtype to extra value of the intent as a hint + // for the input language selection activity. + // intent.putExtra("newlyAddedSubtype", subtypePref.getSubtype()); + startActivity(intent); + } + }); + + return builder.create(); + } + + private void setPrefSubtypes(final String prefSubtypes, final Context context) { + final PreferenceGroup group = getPreferenceScreen(); + group.removeAll(); + final InputMethodSubtype[] subtypesArray = + AdditionalSubtypeUtils.createAdditionalSubtypesArray(prefSubtypes); + for (final InputMethodSubtype subtype : subtypesArray) { + final CustomInputStylePreference pref = + new CustomInputStylePreference(context, subtype, this); + group.addPreference(pref); + } + } + + private InputMethodSubtype[] getSubtypes() { + final PreferenceGroup group = getPreferenceScreen(); + final ArrayList<InputMethodSubtype> subtypes = new ArrayList<>(); + final int count = group.getPreferenceCount(); + for (int i = 0; i < count; i++) { + final Preference pref = group.getPreference(i); + if (pref instanceof CustomInputStylePreference) { + final CustomInputStylePreference subtypePref = (CustomInputStylePreference)pref; + // We should not save newly adding subtype to preference because it is incomplete. + if (subtypePref.isIncomplete()) continue; + subtypes.add(subtypePref.getSubtype()); + } + } + return subtypes.toArray(new InputMethodSubtype[subtypes.size()]); + } + + @Override + public void onPause() { + super.onPause(); + final String oldSubtypes = Settings.readPrefAdditionalSubtypes(mPrefs, getResources()); + final InputMethodSubtype[] subtypes = getSubtypes(); + final String prefSubtypes = AdditionalSubtypeUtils.createPrefSubtypes(subtypes); + if (DEBUG_CUSTOM_INPUT_STYLES) { + Log.i(TAG, "Save custom input styles: " + prefSubtypes); + } + if (prefSubtypes.equals(oldSubtypes)) { + return; + } + Settings.writePrefAdditionalSubtypes(mPrefs, prefSubtypes); + mRichImm.setAdditionalInputMethodSubtypes(subtypes); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + inflater.inflate(R.menu.add_style, menu); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.action_add_style) { + final CustomInputStylePreference newSubtype = + CustomInputStylePreference.newIncompleteSubtypePreference(getActivity(), this); + getPreferenceScreen().addPreference(newSubtype); + newSubtype.show(); + mIsAddingNewSubtype = true; + return true; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/DebugSettings.java b/java/src/org/kelar/inputmethod/latin/settings/DebugSettings.java new file mode 100644 index 000000000..6f26a00b7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/DebugSettings.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2010 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 org.kelar.inputmethod.latin.settings; + +/** + * Debug settings for the application. + * + * Note: Even though these settings are stored in the default shared preferences file, + * they shouldn't be restored across devices. + * If a new key is added here, it should also be blacklisted for restore in + * {@link LocalSettingsConstants}. + */ +public final class DebugSettings { + public static final String PREF_DEBUG_MODE = "debug_mode"; + public static final String PREF_FORCE_NON_DISTINCT_MULTITOUCH = "force_non_distinct_multitouch"; + public static final String PREF_HAS_CUSTOM_KEY_PREVIEW_ANIMATION_PARAMS = + "pref_has_custom_key_preview_animation_params"; + public static final String PREF_RESIZE_KEYBOARD = "pref_resize_keyboard"; + public static final String PREF_KEYBOARD_HEIGHT_SCALE = "pref_keyboard_height_scale"; + public static final String PREF_KEY_PREVIEW_DISMISS_DURATION = + "pref_key_preview_dismiss_duration"; + public static final String PREF_KEY_PREVIEW_DISMISS_END_X_SCALE = + "pref_key_preview_dismiss_end_x_scale"; + public static final String PREF_KEY_PREVIEW_DISMISS_END_Y_SCALE = + "pref_key_preview_dismiss_end_y_scale"; + public static final String PREF_KEY_PREVIEW_SHOW_UP_DURATION = + "pref_key_preview_show_up_duration"; + public static final String PREF_KEY_PREVIEW_SHOW_UP_START_X_SCALE = + "pref_key_preview_show_up_start_x_scale"; + public static final String PREF_KEY_PREVIEW_SHOW_UP_START_Y_SCALE = + "pref_key_preview_show_up_start_y_scale"; + public static final String PREF_SHOULD_SHOW_LXX_SUGGESTION_UI = + "pref_should_show_lxx_suggestion_ui"; + public static final String PREF_SLIDING_KEY_INPUT_PREVIEW = "pref_sliding_key_input_preview"; + + private DebugSettings() { + // This class is not publicly instantiable. + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/DebugSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/DebugSettingsFragment.java new file mode 100644 index 000000000..5cecb8155 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/DebugSettingsFragment.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.Bundle; +import android.os.Process; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceGroup; +import android.preference.TwoStatePreference; + +import org.kelar.inputmethod.latin.DictionaryDumpBroadcastReceiver; +import org.kelar.inputmethod.latin.DictionaryFacilitatorImpl; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.ResourceUtils; + +import java.util.Locale; + +/** + * "Debug mode" settings sub screen. + * + * This settings sub screen handles a several preference options for debugging. + */ +public final class DebugSettingsFragment extends SubScreenFragment + implements OnPreferenceClickListener { + private static final String PREF_KEY_DUMP_DICTS = "pref_key_dump_dictionaries"; + private static final String PREF_KEY_DUMP_DICT_PREFIX = "pref_key_dump_dictionaries"; + + private boolean mServiceNeedsRestart = false; + private TwoStatePreference mDebugMode; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_debug); + + if (!Settings.SHOULD_SHOW_LXX_SUGGESTION_UI) { + removePreference(DebugSettings.PREF_SHOULD_SHOW_LXX_SUGGESTION_UI); + } + + final PreferenceGroup dictDumpPreferenceGroup = + (PreferenceGroup)findPreference(PREF_KEY_DUMP_DICTS); + for (final String dictName : DictionaryFacilitatorImpl.DICT_TYPE_TO_CLASS.keySet()) { + final Preference pref = new DictDumpPreference(getActivity(), dictName); + pref.setOnPreferenceClickListener(this); + dictDumpPreferenceGroup.addPreference(pref); + } + final Resources res = getResources(); + setupKeyPreviewAnimationDuration(DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_DURATION, + res.getInteger(R.integer.config_key_preview_show_up_duration)); + setupKeyPreviewAnimationDuration(DebugSettings.PREF_KEY_PREVIEW_DISMISS_DURATION, + res.getInteger(R.integer.config_key_preview_dismiss_duration)); + final float defaultKeyPreviewShowUpStartScale = ResourceUtils.getFloatFromFraction( + res, R.fraction.config_key_preview_show_up_start_scale); + final float defaultKeyPreviewDismissEndScale = ResourceUtils.getFloatFromFraction( + res, R.fraction.config_key_preview_dismiss_end_scale); + setupKeyPreviewAnimationScale(DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_X_SCALE, + defaultKeyPreviewShowUpStartScale); + setupKeyPreviewAnimationScale(DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_Y_SCALE, + defaultKeyPreviewShowUpStartScale); + setupKeyPreviewAnimationScale(DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_X_SCALE, + defaultKeyPreviewDismissEndScale); + setupKeyPreviewAnimationScale(DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_Y_SCALE, + defaultKeyPreviewDismissEndScale); + setupKeyboardHeight( + DebugSettings.PREF_KEYBOARD_HEIGHT_SCALE, SettingsValues.DEFAULT_SIZE_SCALE); + + mServiceNeedsRestart = false; + mDebugMode = (TwoStatePreference) findPreference(DebugSettings.PREF_DEBUG_MODE); + updateDebugMode(); + } + + private static class DictDumpPreference extends Preference { + public final String mDictName; + + public DictDumpPreference(final Context context, final String dictName) { + super(context); + setKey(PREF_KEY_DUMP_DICT_PREFIX + dictName); + setTitle("Dump " + dictName + " dictionary"); + mDictName = dictName; + } + } + + @Override + public boolean onPreferenceClick(final Preference pref) { + final Context context = getActivity(); + if (pref instanceof DictDumpPreference) { + final DictDumpPreference dictDumpPref = (DictDumpPreference)pref; + final String dictName = dictDumpPref.mDictName; + final Intent intent = new Intent( + DictionaryDumpBroadcastReceiver.DICTIONARY_DUMP_INTENT_ACTION); + intent.putExtra(DictionaryDumpBroadcastReceiver.DICTIONARY_NAME_KEY, dictName); + context.sendBroadcast(intent); + return true; + } + return true; + } + + @Override + public void onStop() { + super.onStop(); + if (mServiceNeedsRestart) { + Process.killProcess(Process.myPid()); + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + if (key.equals(DebugSettings.PREF_DEBUG_MODE) && mDebugMode != null) { + mDebugMode.setChecked(prefs.getBoolean(DebugSettings.PREF_DEBUG_MODE, false)); + updateDebugMode(); + mServiceNeedsRestart = true; + return; + } + if (key.equals(DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH)) { + mServiceNeedsRestart = true; + return; + } + } + + private void updateDebugMode() { + boolean isDebugMode = mDebugMode.isChecked(); + final String version = getString( + R.string.version_text, ApplicationUtils.getVersionName(getActivity())); + if (!isDebugMode) { + mDebugMode.setTitle(version); + mDebugMode.setSummary(null); + } else { + mDebugMode.setTitle(getString(R.string.prefs_debug_mode)); + mDebugMode.setSummary(version); + } + } + + private void setupKeyPreviewAnimationScale(final String prefKey, final float defaultValue) { + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference(prefKey); + if (pref == null) { + return; + } + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + private static final float PERCENTAGE_FLOAT = 100.0f; + + private float getValueFromPercentage(final int percentage) { + return percentage / PERCENTAGE_FLOAT; + } + + private int getPercentageFromValue(final float floatValue) { + return (int)(floatValue * PERCENTAGE_FLOAT); + } + + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putFloat(key, getValueFromPercentage(value)).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return getPercentageFromValue( + Settings.readKeyPreviewAnimationScale(prefs, key, defaultValue)); + } + + @Override + public int readDefaultValue(final String key) { + return getPercentageFromValue(defaultValue); + } + + @Override + public String getValueText(final int value) { + if (value < 0) { + return res.getString(R.string.settings_system_default); + } + return String.format(Locale.ROOT, "%d%%", value); + } + + @Override + public void feedbackValue(final int value) {} + }); + } + + private void setupKeyPreviewAnimationDuration(final String prefKey, final int defaultValue) { + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference(prefKey); + if (pref == null) { + return; + } + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putInt(key, value).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return Settings.readKeyPreviewAnimationDuration(prefs, key, defaultValue); + } + + @Override + public int readDefaultValue(final String key) { + return defaultValue; + } + + @Override + public String getValueText(final int value) { + return res.getString(R.string.abbreviation_unit_milliseconds, value); + } + + @Override + public void feedbackValue(final int value) {} + }); + } + + private void setupKeyboardHeight(final String prefKey, final float defaultValue) { + final SharedPreferences prefs = getSharedPreferences(); + final SeekBarDialogPreference pref = (SeekBarDialogPreference)findPreference(prefKey); + if (pref == null) { + return; + } + pref.setInterface(new SeekBarDialogPreference.ValueProxy() { + private static final float PERCENTAGE_FLOAT = 100.0f; + private float getValueFromPercentage(final int percentage) { + return percentage / PERCENTAGE_FLOAT; + } + + private int getPercentageFromValue(final float floatValue) { + return (int)(floatValue * PERCENTAGE_FLOAT); + } + + @Override + public void writeValue(final int value, final String key) { + prefs.edit().putFloat(key, getValueFromPercentage(value)).apply(); + } + + @Override + public void writeDefaultValue(final String key) { + prefs.edit().remove(key).apply(); + } + + @Override + public int readValue(final String key) { + return getPercentageFromValue(Settings.readKeyboardHeight(prefs, defaultValue)); + } + + @Override + public int readDefaultValue(final String key) { + return getPercentageFromValue(defaultValue); + } + + @Override + public String getValueText(final int value) { + return String.format(Locale.ROOT, "%d%%", value); + } + + @Override + public void feedbackValue(final int value) {} + }); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/GestureSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/GestureSettingsFragment.java new file mode 100644 index 000000000..f26392185 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/GestureSettingsFragment.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.os.Bundle; + +import org.kelar.inputmethod.latin.R; + +/** + * "Gesture typing preferences" settings sub screen. + * + * This settings sub screen handles the following gesture typing preferences. + * - Enable gesture typing + * - Dynamic floating preview + * - Show gesture trail + * - Phrase gesture + */ +public final class GestureSettingsFragment extends SubScreenFragment { + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_gesture); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/LocalSettingsConstants.java b/java/src/org/kelar/inputmethod/latin/settings/LocalSettingsConstants.java new file mode 100644 index 000000000..74551724f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/LocalSettingsConstants.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +/** + * Collection of device specific preference constants. + */ +public class LocalSettingsConstants { + // Preference file for storing preferences that are tied to a device + // and are not backed up. + public static final String PREFS_FILE = "local_prefs"; + + // Preference key for the current account. + // Do not restore. + public static final String PREF_ACCOUNT_NAME = "pref_account_name"; + // Preference key for enabling cloud sync feature. + // Do not restore. + public static final String PREF_ENABLE_CLOUD_SYNC = "pref_enable_cloud_sync"; + + // List of preference keys to skip from being restored by backup agent. + // These preferences are tied to a device and hence should not be restored. + // e.g. account name. + // Ideally they could have been kept in a separate file that wasn't backed up + // however the preference UI currently only deals with the default + // shared preferences which makes it non-trivial to move these out to + // a different shared preferences file. + public static final String[] PREFS_TO_SKIP_RESTORING = new String[] { + PREF_ACCOUNT_NAME, + PREF_ENABLE_CLOUD_SYNC, + // The debug settings are not restored on a new device. + // If a feature relies on these, it should ensure that the defaults are + // correctly set for it to work on a new device. + DebugSettings.PREF_DEBUG_MODE, + DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH, + DebugSettings.PREF_HAS_CUSTOM_KEY_PREVIEW_ANIMATION_PARAMS, + DebugSettings.PREF_KEYBOARD_HEIGHT_SCALE, + DebugSettings.PREF_KEY_PREVIEW_DISMISS_DURATION, + DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_X_SCALE, + DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_Y_SCALE, + DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_DURATION, + DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_X_SCALE, + DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_Y_SCALE, + DebugSettings.PREF_RESIZE_KEYBOARD, + DebugSettings.PREF_SHOULD_SHOW_LXX_SUGGESTION_UI, + DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW + }; +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/PreferencesSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/PreferencesSettingsFragment.java new file mode 100644 index 000000000..3103a7a7f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/PreferencesSettingsFragment.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import android.preference.Preference; + +import org.kelar.inputmethod.latin.AudioAndHapticFeedbackManager; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; + +/** + * "Preferences" settings sub screen. + * + * This settings sub screen handles the following input preferences. + * - Auto-capitalization + * - Double-space period + * - Vibrate on keypress + * - Sound on keypress + * - Popup on keypress + * - Voice input key + */ +public final class PreferencesSettingsFragment extends SubScreenFragment { + + private static final boolean VOICE_IME_ENABLED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_preferences); + + final Resources res = getResources(); + final Context context = getActivity(); + + // When we are called from the Settings application but we are not already running, some + // singleton and utility classes may not have been initialized. We have to call + // initialization method of these classes here. See {@link LatinIME#onCreate()}. + RichInputMethodManager.init(context); + + final boolean showVoiceKeyOption = res.getBoolean( + R.bool.config_enable_show_voice_key_option); + if (!showVoiceKeyOption) { + removePreference(Settings.PREF_VOICE_INPUT_KEY); + } + if (!AudioAndHapticFeedbackManager.getInstance().hasVibrator()) { + removePreference(Settings.PREF_VIBRATE_ON); + } + if (!Settings.readFromBuildConfigIfToShowKeyPreviewPopupOption(res)) { + removePreference(Settings.PREF_POPUP_ON); + } + + refreshEnablingsOfKeypressSoundAndVibrationSettings(); + } + + @Override + public void onResume() { + super.onResume(); + final Preference voiceInputKeyOption = findPreference(Settings.PREF_VOICE_INPUT_KEY); + if (voiceInputKeyOption != null) { + RichInputMethodManager.getInstance().refreshSubtypeCaches(); + voiceInputKeyOption.setEnabled(VOICE_IME_ENABLED); + voiceInputKeyOption.setSummary(VOICE_IME_ENABLED + ? null : getText(R.string.voice_input_disabled_summary)); + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + final Resources res = getResources(); + if (key.equals(Settings.PREF_POPUP_ON)) { + setPreferenceEnabled(Settings.PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY, + Settings.readKeyPreviewPopupEnabled(prefs, res)); + } + refreshEnablingsOfKeypressSoundAndVibrationSettings(); + } + + private void refreshEnablingsOfKeypressSoundAndVibrationSettings() { + final SharedPreferences prefs = getSharedPreferences(); + final Resources res = getResources(); + setPreferenceEnabled(Settings.PREF_VIBRATION_DURATION_SETTINGS, + Settings.readVibrationEnabled(prefs, res)); + setPreferenceEnabled(Settings.PREF_KEYPRESS_SOUND_VOLUME, + Settings.readKeypressSoundEnabled(prefs, res)); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/RadioButtonPreference.java b/java/src/org/kelar/inputmethod/latin/settings/RadioButtonPreference.java new file mode 100644 index 000000000..0993cfe29 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/RadioButtonPreference.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.view.View; +import android.widget.RadioButton; + +import org.kelar.inputmethod.latin.R; + +/** + * Radio Button preference + */ +public class RadioButtonPreference extends Preference { + interface OnRadioButtonClickedListener { + /** + * Called when this preference needs to be saved its state. + * + * @param preference This preference. + */ + public void onRadioButtonClicked(RadioButtonPreference preference); + } + + private boolean mIsSelected; + private RadioButton mRadioButton; + private OnRadioButtonClickedListener mListener; + private final View.OnClickListener mClickListener = new View.OnClickListener() { + @Override + public void onClick(final View v) { + callListenerOnRadioButtonClicked(); + } + }; + + public RadioButtonPreference(final Context context) { + this(context, null); + } + + public RadioButtonPreference(final Context context, final AttributeSet attrs) { + this(context, attrs, android.R.attr.preferenceStyle); + } + + public RadioButtonPreference(final Context context, final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + setWidgetLayoutResource(R.layout.radio_button_preference_widget); + } + + public void setOnRadioButtonClickedListener(final OnRadioButtonClickedListener listener) { + mListener = listener; + } + + void callListenerOnRadioButtonClicked() { + if (mListener != null) { + mListener.onRadioButtonClicked(this); + } + } + + @Override + protected void onBindView(final View view) { + super.onBindView(view); + mRadioButton = (RadioButton)view.findViewById(R.id.radio_button); + mRadioButton.setChecked(mIsSelected); + mRadioButton.setOnClickListener(mClickListener); + view.setOnClickListener(mClickListener); + } + + public boolean isSelected() { + return mIsSelected; + } + + public void setSelected(final boolean selected) { + if (selected == mIsSelected) { + return; + } + mIsSelected = selected; + if (mRadioButton != null) { + mRadioButton.setChecked(selected); + } + notifyChanged(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SeekBarDialogPreference.java b/java/src/org/kelar/inputmethod/latin/settings/SeekBarDialogPreference.java new file mode 100644 index 000000000..a5437cf13 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SeekBarDialogPreference.java @@ -0,0 +1,147 @@ +/* + * 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 org.kelar.inputmethod.latin.settings; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.TypedArray; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.view.View; +import android.widget.SeekBar; +import android.widget.TextView; + +import org.kelar.inputmethod.latin.R; + +public final class SeekBarDialogPreference extends DialogPreference + implements SeekBar.OnSeekBarChangeListener { + public interface ValueProxy { + public int readValue(final String key); + public int readDefaultValue(final String key); + public void writeValue(final int value, final String key); + public void writeDefaultValue(final String key); + public String getValueText(final int value); + public void feedbackValue(final int value); + } + + private final int mMaxValue; + private final int mMinValue; + private final int mStepValue; + + private TextView mValueView; + private SeekBar mSeekBar; + + private ValueProxy mValueProxy; + + public SeekBarDialogPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.SeekBarDialogPreference, 0, 0); + mMaxValue = a.getInt(R.styleable.SeekBarDialogPreference_maxValue, 0); + mMinValue = a.getInt(R.styleable.SeekBarDialogPreference_minValue, 0); + mStepValue = a.getInt(R.styleable.SeekBarDialogPreference_stepValue, 0); + a.recycle(); + setDialogLayoutResource(R.layout.seek_bar_dialog); + } + + public void setInterface(final ValueProxy proxy) { + mValueProxy = proxy; + final int value = mValueProxy.readValue(getKey()); + setSummary(mValueProxy.getValueText(value)); + } + + @Override + protected View onCreateDialogView() { + final View view = super.onCreateDialogView(); + mSeekBar = (SeekBar)view.findViewById(R.id.seek_bar_dialog_bar); + mSeekBar.setMax(mMaxValue - mMinValue); + mSeekBar.setOnSeekBarChangeListener(this); + mValueView = (TextView)view.findViewById(R.id.seek_bar_dialog_value); + return view; + } + + private int getProgressFromValue(final int value) { + return value - mMinValue; + } + + private int getValueFromProgress(final int progress) { + return progress + mMinValue; + } + + private int clipValue(final int value) { + final int clippedValue = Math.min(mMaxValue, Math.max(mMinValue, value)); + if (mStepValue <= 1) { + return clippedValue; + } + return clippedValue - (clippedValue % mStepValue); + } + + private int getClippedValueFromProgress(final int progress) { + return clipValue(getValueFromProgress(progress)); + } + + @Override + protected void onBindDialogView(final View view) { + final int value = mValueProxy.readValue(getKey()); + mValueView.setText(mValueProxy.getValueText(value)); + mSeekBar.setProgress(getProgressFromValue(clipValue(value))); + } + + @Override + protected void onPrepareDialogBuilder(final AlertDialog.Builder builder) { + builder.setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, this) + .setNeutralButton(R.string.button_default, this); + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + super.onClick(dialog, which); + final String key = getKey(); + if (which == DialogInterface.BUTTON_NEUTRAL) { + final int value = mValueProxy.readDefaultValue(key); + setSummary(mValueProxy.getValueText(value)); + mValueProxy.writeDefaultValue(key); + return; + } + if (which == DialogInterface.BUTTON_POSITIVE) { + final int value = getClippedValueFromProgress(mSeekBar.getProgress()); + setSummary(mValueProxy.getValueText(value)); + mValueProxy.writeValue(value, key); + return; + } + } + + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + final int value = getClippedValueFromProgress(progress); + mValueView.setText(mValueProxy.getValueText(value)); + if (!fromUser) { + mSeekBar.setProgress(getProgressFromValue(value)); + } + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + mValueProxy.feedbackValue(getClippedValueFromProgress(seekBar.getProgress())); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/Settings.java b/java/src/org/kelar/inputmethod/latin/settings/Settings.java new file mode 100644 index 000000000..c16caddb2 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/Settings.java @@ -0,0 +1,458 @@ +/* + * 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.preference.PreferenceManager; +import android.util.Log; + +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.latin.AudioAndHapticFeedbackManager; +import org.kelar.inputmethod.latin.InputAttributes; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils; +import org.kelar.inputmethod.latin.utils.ResourceUtils; +import org.kelar.inputmethod.latin.utils.RunInLocale; +import org.kelar.inputmethod.latin.utils.StatsUtils; + +import java.util.Collections; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +import javax.annotation.Nonnull; + +public final class Settings implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = Settings.class.getSimpleName(); + // Settings screens + public static final String SCREEN_ACCOUNTS = "screen_accounts"; + public static final String SCREEN_THEME = "screen_theme"; + public static final String SCREEN_DEBUG = "screen_debug"; + // In the same order as xml/prefs.xml + public static final String PREF_AUTO_CAP = "auto_cap"; + public static final String PREF_VIBRATE_ON = "vibrate_on"; + public static final String PREF_SOUND_ON = "sound_on"; + public static final String PREF_POPUP_ON = "popup_on"; + // PREF_VOICE_MODE_OBSOLETE is obsolete. Use PREF_VOICE_INPUT_KEY instead. + public static final String PREF_VOICE_MODE_OBSOLETE = "voice_mode"; + public static final String PREF_VOICE_INPUT_KEY = "pref_voice_input_key"; + public static final String PREF_EDIT_PERSONAL_DICTIONARY = "edit_personal_dictionary"; + public static final String PREF_CONFIGURE_DICTIONARIES_KEY = "configure_dictionaries_key"; + // PREF_AUTO_CORRECTION_THRESHOLD_OBSOLETE is obsolete. Use PREF_AUTO_CORRECTION instead. + public static final String PREF_AUTO_CORRECTION_THRESHOLD_OBSOLETE = + "auto_correction_threshold"; + public static final String PREF_AUTO_CORRECTION = "pref_key_auto_correction"; + // PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE is obsolete. Use PREF_SHOW_SUGGESTIONS instead. + public static final String PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE = "show_suggestions_setting"; + public static final String PREF_SHOW_SUGGESTIONS = "show_suggestions"; + public static final String PREF_KEY_USE_CONTACTS_DICT = "pref_key_use_contacts_dict"; + public static final String PREF_KEY_USE_PERSONALIZED_DICTS = "pref_key_use_personalized_dicts"; + public static final String PREF_KEY_USE_DOUBLE_SPACE_PERIOD = + "pref_key_use_double_space_period"; + public static final String PREF_BLOCK_POTENTIALLY_OFFENSIVE = + "pref_key_block_potentially_offensive"; + public static final boolean ENABLE_SHOW_LANGUAGE_SWITCH_KEY_SETTINGS = + BuildCompatUtils.EFFECTIVE_SDK_INT <= Build.VERSION_CODES.KITKAT; + public static final boolean SHOULD_SHOW_LXX_SUGGESTION_UI = + BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + public static final String PREF_SHOW_LANGUAGE_SWITCH_KEY = + "pref_show_language_switch_key"; + public static final String PREF_INCLUDE_OTHER_IMES_IN_LANGUAGE_SWITCH_LIST = + "pref_include_other_imes_in_language_switch_list"; + public static final String PREF_CUSTOM_INPUT_STYLES = "custom_input_styles"; + public static final String PREF_ENABLE_SPLIT_KEYBOARD = "pref_split_keyboard"; + // TODO: consolidate key preview dismiss delay with the key preview animation parameters. + public static final String PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY = + "pref_key_preview_popup_dismiss_delay"; + public static final String PREF_BIGRAM_PREDICTIONS = "next_word_prediction"; + public static final String PREF_GESTURE_INPUT = "gesture_input"; + public static final String PREF_VIBRATION_DURATION_SETTINGS = + "pref_vibration_duration_settings"; + public static final String PREF_KEYPRESS_SOUND_VOLUME = "pref_keypress_sound_volume"; + public static final String PREF_KEY_LONGPRESS_TIMEOUT = "pref_key_longpress_timeout"; + public static final String PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY = + "pref_enable_emoji_alt_physical_key"; + public static final String PREF_GESTURE_PREVIEW_TRAIL = "pref_gesture_preview_trail"; + public static final String PREF_GESTURE_FLOATING_PREVIEW_TEXT = + "pref_gesture_floating_preview_text"; + public static final String PREF_SHOW_SETUP_WIZARD_ICON = "pref_show_setup_wizard_icon"; + + public static final String PREF_KEY_IS_INTERNAL = "pref_key_is_internal"; + + public static final String PREF_ENABLE_METRICS_LOGGING = "pref_enable_metrics_logging"; + // This preference key is deprecated. Use {@link #PREF_SHOW_LANGUAGE_SWITCH_KEY} instead. + // This is being used only for the backward compatibility. + private static final String PREF_SUPPRESS_LANGUAGE_SWITCH_KEY = + "pref_suppress_language_switch_key"; + + private static final String PREF_LAST_USED_PERSONALIZATION_TOKEN = + "pref_last_used_personalization_token"; + private static final String PREF_LAST_PERSONALIZATION_DICT_WIPED_TIME = + "pref_last_used_personalization_dict_wiped_time"; + private static final String PREF_CORPUS_HANDLES_FOR_PERSONALIZATION = + "pref_corpus_handles_for_personalization"; + + // Emoji + public static final String PREF_EMOJI_RECENT_KEYS = "emoji_recent_keys"; + public static final String PREF_EMOJI_CATEGORY_LAST_TYPED_ID = "emoji_category_last_typed_id"; + public static final String PREF_LAST_SHOWN_EMOJI_CATEGORY_ID = "last_shown_emoji_category_id"; + + private static final float UNDEFINED_PREFERENCE_VALUE_FLOAT = -1.0f; + private static final int UNDEFINED_PREFERENCE_VALUE_INT = -1; + + private Context mContext; + private Resources mRes; + private SharedPreferences mPrefs; + private SettingsValues mSettingsValues; + private final ReentrantLock mSettingsValuesLock = new ReentrantLock(); + + private static final Settings sInstance = new Settings(); + + public static Settings getInstance() { + return sInstance; + } + + public static void init(final Context context) { + sInstance.onCreate(context); + } + + private Settings() { + // Intentional empty constructor for singleton. + } + + private void onCreate(final Context context) { + mContext = context; + mRes = context.getResources(); + mPrefs = PreferenceManager.getDefaultSharedPreferences(context); + mPrefs.registerOnSharedPreferenceChangeListener(this); + upgradeAutocorrectionSettings(mPrefs, mRes); + } + + public void onDestroy() { + mPrefs.unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + mSettingsValuesLock.lock(); + try { + if (mSettingsValues == null) { + // TODO: Introduce a static function to register this class and ensure that + // loadSettings must be called before "onSharedPreferenceChanged" is called. + Log.w(TAG, "onSharedPreferenceChanged called before loadSettings."); + return; + } + loadSettings(mContext, mSettingsValues.mLocale, mSettingsValues.mInputAttributes); + StatsUtils.onLoadSettings(mSettingsValues); + } finally { + mSettingsValuesLock.unlock(); + } + } + + public void loadSettings(final Context context, final Locale locale, + @Nonnull final InputAttributes inputAttributes) { + mSettingsValuesLock.lock(); + mContext = context; + try { + final SharedPreferences prefs = mPrefs; + final RunInLocale<SettingsValues> job = new RunInLocale<SettingsValues>() { + @Override + protected SettingsValues job(final Resources res) { + return new SettingsValues(context, prefs, res, inputAttributes); + } + }; + mSettingsValues = job.runInLocale(mRes, locale); + } finally { + mSettingsValuesLock.unlock(); + } + } + + // TODO: Remove this method and add proxy method to SettingsValues. + public SettingsValues getCurrent() { + return mSettingsValues; + } + + public boolean isInternal() { + return mSettingsValues.mIsInternal; + } + + public static int readScreenMetrics(final Resources res) { + return res.getInteger(R.integer.config_screen_metrics); + } + + // Accessed from the settings interface, hence public + public static boolean readKeypressSoundEnabled(final SharedPreferences prefs, + final Resources res) { + return prefs.getBoolean(PREF_SOUND_ON, + res.getBoolean(R.bool.config_default_sound_enabled)); + } + + public static boolean readVibrationEnabled(final SharedPreferences prefs, + final Resources res) { + final boolean hasVibrator = AudioAndHapticFeedbackManager.getInstance().hasVibrator(); + return hasVibrator && prefs.getBoolean(PREF_VIBRATE_ON, + res.getBoolean(R.bool.config_default_vibration_enabled)); + } + + public static boolean readAutoCorrectEnabled(final SharedPreferences prefs, + final Resources res) { + return prefs.getBoolean(PREF_AUTO_CORRECTION, true); + } + + public static float readPlausibilityThreshold(final Resources res) { + return Float.parseFloat(res.getString(R.string.plausibility_threshold)); + } + + public static boolean readBlockPotentiallyOffensive(final SharedPreferences prefs, + final Resources res) { + return prefs.getBoolean(PREF_BLOCK_POTENTIALLY_OFFENSIVE, + res.getBoolean(R.bool.config_block_potentially_offensive)); + } + + public static boolean readFromBuildConfigIfGestureInputEnabled(final Resources res) { + return res.getBoolean(R.bool.config_gesture_input_enabled_by_build_config); + } + + public static boolean readGestureInputEnabled(final SharedPreferences prefs, + final Resources res) { + return readFromBuildConfigIfGestureInputEnabled(res) + && prefs.getBoolean(PREF_GESTURE_INPUT, true); + } + + public static boolean readFromBuildConfigIfToShowKeyPreviewPopupOption(final Resources res) { + return res.getBoolean(R.bool.config_enable_show_key_preview_popup_option); + } + + public static boolean readKeyPreviewPopupEnabled(final SharedPreferences prefs, + final Resources res) { + final boolean defaultKeyPreviewPopup = res.getBoolean( + R.bool.config_default_key_preview_popup); + if (!readFromBuildConfigIfToShowKeyPreviewPopupOption(res)) { + return defaultKeyPreviewPopup; + } + return prefs.getBoolean(PREF_POPUP_ON, defaultKeyPreviewPopup); + } + + public static int readKeyPreviewPopupDismissDelay(final SharedPreferences prefs, + final Resources res) { + return Integer.parseInt(prefs.getString(PREF_KEY_PREVIEW_POPUP_DISMISS_DELAY, + Integer.toString(res.getInteger( + R.integer.config_key_preview_linger_timeout)))); + } + + public static boolean readShowsLanguageSwitchKey(final SharedPreferences prefs) { + if (prefs.contains(PREF_SUPPRESS_LANGUAGE_SWITCH_KEY)) { + final boolean suppressLanguageSwitchKey = prefs.getBoolean( + PREF_SUPPRESS_LANGUAGE_SWITCH_KEY, false); + final SharedPreferences.Editor editor = prefs.edit(); + editor.remove(PREF_SUPPRESS_LANGUAGE_SWITCH_KEY); + editor.putBoolean(PREF_SHOW_LANGUAGE_SWITCH_KEY, !suppressLanguageSwitchKey); + editor.apply(); + } + return prefs.getBoolean(PREF_SHOW_LANGUAGE_SWITCH_KEY, true); + } + + public static String readPrefAdditionalSubtypes(final SharedPreferences prefs, + final Resources res) { + final String predefinedPrefSubtypes = AdditionalSubtypeUtils.createPrefSubtypes( + res.getStringArray(R.array.predefined_subtypes)); + return prefs.getString(PREF_CUSTOM_INPUT_STYLES, predefinedPrefSubtypes); + } + + public static void writePrefAdditionalSubtypes(final SharedPreferences prefs, + final String prefSubtypes) { + prefs.edit().putString(PREF_CUSTOM_INPUT_STYLES, prefSubtypes).apply(); + } + + public static float readKeypressSoundVolume(final SharedPreferences prefs, + final Resources res) { + final float volume = prefs.getFloat( + PREF_KEYPRESS_SOUND_VOLUME, UNDEFINED_PREFERENCE_VALUE_FLOAT); + return (volume != UNDEFINED_PREFERENCE_VALUE_FLOAT) ? volume + : readDefaultKeypressSoundVolume(res); + } + + // Default keypress sound volume for unknown devices. + // The negative value means system default. + private static final String DEFAULT_KEYPRESS_SOUND_VOLUME = Float.toString(-1.0f); + + public static float readDefaultKeypressSoundVolume(final Resources res) { + return Float.parseFloat(ResourceUtils.getDeviceOverrideValue(res, + R.array.keypress_volumes, DEFAULT_KEYPRESS_SOUND_VOLUME)); + } + + public static int readKeyLongpressTimeout(final SharedPreferences prefs, + final Resources res) { + final int milliseconds = prefs.getInt( + PREF_KEY_LONGPRESS_TIMEOUT, UNDEFINED_PREFERENCE_VALUE_INT); + return (milliseconds != UNDEFINED_PREFERENCE_VALUE_INT) ? milliseconds + : readDefaultKeyLongpressTimeout(res); + } + + public static int readDefaultKeyLongpressTimeout(final Resources res) { + return res.getInteger(R.integer.config_default_longpress_key_timeout); + } + + public static int readKeypressVibrationDuration(final SharedPreferences prefs, + final Resources res) { + final int milliseconds = prefs.getInt( + PREF_VIBRATION_DURATION_SETTINGS, UNDEFINED_PREFERENCE_VALUE_INT); + return (milliseconds != UNDEFINED_PREFERENCE_VALUE_INT) ? milliseconds + : readDefaultKeypressVibrationDuration(res); + } + + // Default keypress vibration duration for unknown devices. + // The negative value means system default. + private static final String DEFAULT_KEYPRESS_VIBRATION_DURATION = Integer.toString(-1); + + public static int readDefaultKeypressVibrationDuration(final Resources res) { + return Integer.parseInt(ResourceUtils.getDeviceOverrideValue(res, + R.array.keypress_vibration_durations, DEFAULT_KEYPRESS_VIBRATION_DURATION)); + } + + public static float readKeyPreviewAnimationScale(final SharedPreferences prefs, + final String prefKey, final float defaultValue) { + final float fraction = prefs.getFloat(prefKey, UNDEFINED_PREFERENCE_VALUE_FLOAT); + return (fraction != UNDEFINED_PREFERENCE_VALUE_FLOAT) ? fraction : defaultValue; + } + + public static int readKeyPreviewAnimationDuration(final SharedPreferences prefs, + final String prefKey, final int defaultValue) { + final int milliseconds = prefs.getInt(prefKey, UNDEFINED_PREFERENCE_VALUE_INT); + return (milliseconds != UNDEFINED_PREFERENCE_VALUE_INT) ? milliseconds : defaultValue; + } + + public static float readKeyboardHeight(final SharedPreferences prefs, + final float defaultValue) { + final float percentage = prefs.getFloat( + DebugSettings.PREF_KEYBOARD_HEIGHT_SCALE, UNDEFINED_PREFERENCE_VALUE_FLOAT); + return (percentage != UNDEFINED_PREFERENCE_VALUE_FLOAT) ? percentage : defaultValue; + } + + public static boolean readUseFullscreenMode(final Resources res) { + return res.getBoolean(R.bool.config_use_fullscreen_mode); + } + + public static boolean readShowSetupWizardIcon(final SharedPreferences prefs, + final Context context) { + if (!prefs.contains(PREF_SHOW_SETUP_WIZARD_ICON)) { + final ApplicationInfo appInfo = context.getApplicationInfo(); + final boolean isApplicationInSystemImage = + (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + // Default value + return !isApplicationInSystemImage; + } + return prefs.getBoolean(PREF_SHOW_SETUP_WIZARD_ICON, false); + } + + public static boolean readHasHardwareKeyboard(final Configuration conf) { + // The standard way of finding out whether we have a hardware keyboard. This code is taken + // from InputMethodService#onEvaluateInputShown, which canonically determines this. + // In a nutshell, we have a keyboard if the configuration says the type of hardware keyboard + // is NOKEYS and if it's not hidden (e.g. folded inside the device). + return conf.keyboard != Configuration.KEYBOARD_NOKEYS + && conf.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES; + } + + public static boolean isInternal(final SharedPreferences prefs) { + return prefs.getBoolean(PREF_KEY_IS_INTERNAL, false); + } + + public void writeLastUsedPersonalizationToken(byte[] token) { + if (token == null) { + mPrefs.edit().remove(PREF_LAST_USED_PERSONALIZATION_TOKEN).apply(); + } else { + final String tokenStr = StringUtils.byteArrayToHexString(token); + mPrefs.edit().putString(PREF_LAST_USED_PERSONALIZATION_TOKEN, tokenStr).apply(); + } + } + + public byte[] readLastUsedPersonalizationToken() { + final String tokenStr = mPrefs.getString(PREF_LAST_USED_PERSONALIZATION_TOKEN, null); + return StringUtils.hexStringToByteArray(tokenStr); + } + + public void writeLastPersonalizationDictWipedTime(final long timestamp) { + mPrefs.edit().putLong(PREF_LAST_PERSONALIZATION_DICT_WIPED_TIME, timestamp).apply(); + } + + public long readLastPersonalizationDictGeneratedTime() { + return mPrefs.getLong(PREF_LAST_PERSONALIZATION_DICT_WIPED_TIME, 0); + } + + public void writeCorpusHandlesForPersonalization(final Set<String> corpusHandles) { + mPrefs.edit().putStringSet(PREF_CORPUS_HANDLES_FOR_PERSONALIZATION, corpusHandles).apply(); + } + + public Set<String> readCorpusHandlesForPersonalization() { + final Set<String> emptySet = Collections.emptySet(); + return mPrefs.getStringSet(PREF_CORPUS_HANDLES_FOR_PERSONALIZATION, emptySet); + } + + public static void writeEmojiRecentKeys(final SharedPreferences prefs, String str) { + prefs.edit().putString(PREF_EMOJI_RECENT_KEYS, str).apply(); + } + + public static String readEmojiRecentKeys(final SharedPreferences prefs) { + return prefs.getString(PREF_EMOJI_RECENT_KEYS, ""); + } + + public static void writeLastTypedEmojiCategoryPageId( + final SharedPreferences prefs, final int categoryId, final int categoryPageId) { + final String key = PREF_EMOJI_CATEGORY_LAST_TYPED_ID + categoryId; + prefs.edit().putInt(key, categoryPageId).apply(); + } + + public static int readLastTypedEmojiCategoryPageId( + final SharedPreferences prefs, final int categoryId) { + final String key = PREF_EMOJI_CATEGORY_LAST_TYPED_ID + categoryId; + return prefs.getInt(key, 0); + } + + public static void writeLastShownEmojiCategoryId( + final SharedPreferences prefs, final int categoryId) { + prefs.edit().putInt(PREF_LAST_SHOWN_EMOJI_CATEGORY_ID, categoryId).apply(); + } + + public static int readLastShownEmojiCategoryId( + final SharedPreferences prefs, final int defValue) { + return prefs.getInt(PREF_LAST_SHOWN_EMOJI_CATEGORY_ID, defValue); + } + + private void upgradeAutocorrectionSettings(final SharedPreferences prefs, final Resources res) { + final String thresholdSetting = + prefs.getString(PREF_AUTO_CORRECTION_THRESHOLD_OBSOLETE, null); + if (thresholdSetting != null) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(PREF_AUTO_CORRECTION_THRESHOLD_OBSOLETE); + final String autoCorrectionOff = + res.getString(R.string.auto_correction_threshold_mode_index_off); + if (thresholdSetting.equals(autoCorrectionOff)) { + editor.putBoolean(PREF_AUTO_CORRECTION, false); + } else { + editor.putBoolean(PREF_AUTO_CORRECTION, true); + } + editor.commit(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SettingsActivity.java b/java/src/org/kelar/inputmethod/latin/settings/SettingsActivity.java new file mode 100644 index 000000000..a11cf47e6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SettingsActivity.java @@ -0,0 +1,87 @@ +/* + * 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 org.kelar.inputmethod.latin.settings; + +import org.kelar.inputmethod.latin.permissions.PermissionsManager; +import org.kelar.inputmethod.latin.utils.FragmentUtils; +import org.kelar.inputmethod.latin.utils.StatsUtils; + +import android.app.ActionBar; +import android.content.Intent; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import androidx.core.app.ActivityCompat; +import android.view.MenuItem; + +public final class SettingsActivity extends PreferenceActivity + implements ActivityCompat.OnRequestPermissionsResultCallback { + private static final String DEFAULT_FRAGMENT = SettingsFragment.class.getName(); + + public static final String EXTRA_SHOW_HOME_AS_UP = "show_home_as_up"; + public static final String EXTRA_ENTRY_KEY = "entry"; + public static final String EXTRA_ENTRY_VALUE_LONG_PRESS_COMMA = "long_press_comma"; + public static final String EXTRA_ENTRY_VALUE_APP_ICON = "app_icon"; + public static final String EXTRA_ENTRY_VALUE_NOTICE_DIALOG = "important_notice"; + public static final String EXTRA_ENTRY_VALUE_SYSTEM_SETTINGS = "system_settings"; + + private boolean mShowHomeAsUp; + + @Override + protected void onCreate(final Bundle savedState) { + super.onCreate(savedState); + final ActionBar actionBar = getActionBar(); + final Intent intent = getIntent(); + if (actionBar != null) { + mShowHomeAsUp = intent.getBooleanExtra(EXTRA_SHOW_HOME_AS_UP, true); + actionBar.setDisplayHomeAsUpEnabled(mShowHomeAsUp); + actionBar.setHomeButtonEnabled(mShowHomeAsUp); + } + StatsUtils.onSettingsActivity( + intent.hasExtra(EXTRA_ENTRY_KEY) ? intent.getStringExtra(EXTRA_ENTRY_KEY) + : EXTRA_ENTRY_VALUE_SYSTEM_SETTINGS); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (mShowHomeAsUp && item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public Intent getIntent() { + final Intent intent = super.getIntent(); + final String fragment = intent.getStringExtra(EXTRA_SHOW_FRAGMENT); + if (fragment == null) { + intent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT); + } + intent.putExtra(EXTRA_NO_HEADERS, true); + return intent; + } + + @Override + public boolean isValidFragment(final String fragmentName) { + return FragmentUtils.isValidFragment(fragmentName); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + PermissionsManager.get(this).onRequestPermissionsResult(requestCode, permissions, grantResults); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/SettingsFragment.java new file mode 100644 index 000000000..b61d418f6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SettingsFragment.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2008 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 org.kelar.inputmethod.latin.settings; + +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceScreen; +import android.provider.Settings.Secure; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.define.ProductionFlags; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; +import org.kelar.inputmethod.latin.utils.FeedbackUtils; +import org.kelar.inputmethodcommon.InputMethodSettingsFragment; + +public final class SettingsFragment extends InputMethodSettingsFragment { + // We don't care about menu grouping. + private static final int NO_MENU_GROUP = Menu.NONE; + // The first menu item id and order. + private static final int MENU_ABOUT = Menu.FIRST; + // The second menu item id and order. + private static final int MENU_HELP_AND_FEEDBACK = Menu.FIRST + 1; + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + setHasOptionsMenu(true); + setInputMethodSettingsCategoryTitle(R.string.language_selection_title); + setSubtypeEnablerTitle(R.string.select_language); + addPreferencesFromResource(R.xml.prefs); + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + preferenceScreen.setTitle( + ApplicationUtils.getActivityTitleResId(getActivity(), SettingsActivity.class)); + if (!ProductionFlags.ENABLE_ACCOUNT_SIGN_IN) { + final Preference accountsPreference = findPreference(Settings.SCREEN_ACCOUNTS); + preferenceScreen.removePreference(accountsPreference); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (FeedbackUtils.isHelpAndFeedbackFormSupported()) { + menu.add(NO_MENU_GROUP, MENU_HELP_AND_FEEDBACK /* itemId */, + MENU_HELP_AND_FEEDBACK /* order */, R.string.help_and_feedback); + } + final int aboutResId = FeedbackUtils.getAboutKeyboardTitleResId(); + if (aboutResId != 0) { + menu.add(NO_MENU_GROUP, MENU_ABOUT /* itemId */, MENU_ABOUT /* order */, aboutResId); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + final Activity activity = getActivity(); + if (!isUserSetupComplete(activity)) { + // If setup is not complete, it's not safe to launch Help or other activities + // because they might go to the Play Store. See b/19866981. + return true; + } + final int itemId = item.getItemId(); + if (itemId == MENU_HELP_AND_FEEDBACK) { + FeedbackUtils.showHelpAndFeedbackForm(activity); + return true; + } + if (itemId == MENU_ABOUT) { + final Intent aboutIntent = FeedbackUtils.getAboutKeyboardIntent(activity); + if (aboutIntent != null) { + startActivity(aboutIntent); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + private static boolean isUserSetupComplete(final Activity activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return true; + } + return Secure.getInt(activity.getContentResolver(), "user_setup_complete", 0) != 0; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SettingsValues.java b/java/src/org/kelar/inputmethod/latin/settings/SettingsValues.java new file mode 100644 index 000000000..f54a70361 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SettingsValues.java @@ -0,0 +1,453 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.util.Log; +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.compat.AppWorkaroundsUtils; +import org.kelar.inputmethod.latin.InputAttributes; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.utils.AsyncResultHolder; +import org.kelar.inputmethod.latin.utils.ResourceUtils; +import org.kelar.inputmethod.latin.utils.TargetPackageInfoGetterTask; +import org.kelar.inputmethod.latin.utils.RunInLocale; + +import java.util.Arrays; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * When you call the constructor of this class, you may want to change the current system locale by + * using {@link RunInLocale}. + */ +// Non-final for testing via mock library. +public class SettingsValues { + private static final String TAG = SettingsValues.class.getSimpleName(); + // "floatMaxValue" and "floatNegativeInfinity" are special marker strings for + // Float.NEGATIVE_INFINITE and Float.MAX_VALUE. Currently used for auto-correction settings. + private static final String FLOAT_MAX_VALUE_MARKER_STRING = "floatMaxValue"; + private static final String FLOAT_NEGATIVE_INFINITY_MARKER_STRING = "floatNegativeInfinity"; + private static final int TIMEOUT_TO_GET_TARGET_PACKAGE = 5; // seconds + public static final float DEFAULT_SIZE_SCALE = 1.0f; // 100% + + // From resources: + public final SpacingAndPunctuations mSpacingAndPunctuations; + public final int mDelayInMillisecondsToUpdateOldSuggestions; + public final long mDoubleSpacePeriodTimeout; + // From configuration: + public final Locale mLocale; + public final boolean mHasHardwareKeyboard; + public final int mDisplayOrientation; + // From preferences, in the same order as xml/prefs.xml: + public final boolean mAutoCap; + public final boolean mVibrateOn; + public final boolean mSoundOn; + public final boolean mKeyPreviewPopupOn; + public final boolean mShowsVoiceInputKey; + public final boolean mIncludesOtherImesInLanguageSwitchList; + public final boolean mShowsLanguageSwitchKey; + public final boolean mUseContactsDict; + public final boolean mUsePersonalizedDicts; + public final boolean mUseDoubleSpacePeriod; + public final boolean mBlockPotentiallyOffensive; + // Use bigrams to predict the next word when there is no input for it yet + public final boolean mBigramPredictionEnabled; + public final boolean mGestureInputEnabled; + public final boolean mGestureTrailEnabled; + public final boolean mGestureFloatingPreviewTextEnabled; + public final boolean mSlidingKeyInputPreviewEnabled; + public final int mKeyLongpressTimeout; + public final boolean mEnableEmojiAltPhysicalKey; + public final boolean mShowAppIcon; + public final boolean mIsShowAppIconSettingInPreferences; + public final boolean mCloudSyncEnabled; + public final boolean mEnableMetricsLogging; + public final boolean mShouldShowLxxSuggestionUi; + // Use split layout for keyboard. + public final boolean mIsSplitKeyboardEnabled; + public final int mScreenMetrics; + + // From the input box + @Nonnull + public final InputAttributes mInputAttributes; + + // Deduced settings + public final int mKeypressVibrationDuration; + public final float mKeypressSoundVolume; + public final int mKeyPreviewPopupDismissDelay; + private final boolean mAutoCorrectEnabled; + public final float mAutoCorrectionThreshold; + public final float mPlausibilityThreshold; + public final boolean mAutoCorrectionEnabledPerUserSettings; + private final boolean mSuggestionsEnabledPerUserSettings; + private final AsyncResultHolder<AppWorkaroundsUtils> mAppWorkarounds; + + // Debug settings + public final boolean mIsInternal; + public final boolean mHasCustomKeyPreviewAnimationParams; + public final boolean mHasKeyboardResize; + public final float mKeyboardHeightScale; + public final int mKeyPreviewShowUpDuration; + public final int mKeyPreviewDismissDuration; + public final float mKeyPreviewShowUpStartXScale; + public final float mKeyPreviewShowUpStartYScale; + public final float mKeyPreviewDismissEndXScale; + public final float mKeyPreviewDismissEndYScale; + + @Nullable public final String mAccount; + + public SettingsValues(final Context context, final SharedPreferences prefs, final Resources res, + @Nonnull final InputAttributes inputAttributes) { + mLocale = res.getConfiguration().locale; + // Get the resources + mDelayInMillisecondsToUpdateOldSuggestions = + res.getInteger(R.integer.config_delay_in_milliseconds_to_update_old_suggestions); + mSpacingAndPunctuations = new SpacingAndPunctuations(res); + + // Store the input attributes + mInputAttributes = inputAttributes; + + // Get the settings preferences + mAutoCap = prefs.getBoolean(Settings.PREF_AUTO_CAP, true); + mVibrateOn = Settings.readVibrationEnabled(prefs, res); + mSoundOn = Settings.readKeypressSoundEnabled(prefs, res); + mKeyPreviewPopupOn = Settings.readKeyPreviewPopupEnabled(prefs, res); + mSlidingKeyInputPreviewEnabled = prefs.getBoolean( + DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW, true); + mShowsVoiceInputKey = needsToShowVoiceInputKey(prefs, res) + && mInputAttributes.mShouldShowVoiceInputKey + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + mIncludesOtherImesInLanguageSwitchList = Settings.ENABLE_SHOW_LANGUAGE_SWITCH_KEY_SETTINGS + ? prefs.getBoolean(Settings.PREF_INCLUDE_OTHER_IMES_IN_LANGUAGE_SWITCH_LIST, false) + : true /* forcibly */; + mShowsLanguageSwitchKey = Settings.ENABLE_SHOW_LANGUAGE_SWITCH_KEY_SETTINGS + ? Settings.readShowsLanguageSwitchKey(prefs) : true /* forcibly */; + mUseContactsDict = prefs.getBoolean(Settings.PREF_KEY_USE_CONTACTS_DICT, true); + mUsePersonalizedDicts = prefs.getBoolean(Settings.PREF_KEY_USE_PERSONALIZED_DICTS, true); + mUseDoubleSpacePeriod = prefs.getBoolean(Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD, true) + && inputAttributes.mIsGeneralTextInput; + mBlockPotentiallyOffensive = Settings.readBlockPotentiallyOffensive(prefs, res); + mAutoCorrectEnabled = Settings.readAutoCorrectEnabled(prefs, res); + final String autoCorrectionThresholdRawValue = mAutoCorrectEnabled + ? res.getString(R.string.auto_correction_threshold_mode_index_modest) + : res.getString(R.string.auto_correction_threshold_mode_index_off); + mBigramPredictionEnabled = readBigramPredictionEnabled(prefs, res); + mDoubleSpacePeriodTimeout = res.getInteger(R.integer.config_double_space_period_timeout); + mHasHardwareKeyboard = Settings.readHasHardwareKeyboard(res.getConfiguration()); + mEnableMetricsLogging = prefs.getBoolean(Settings.PREF_ENABLE_METRICS_LOGGING, true); + mIsSplitKeyboardEnabled = prefs.getBoolean(Settings.PREF_ENABLE_SPLIT_KEYBOARD, false); + mScreenMetrics = Settings.readScreenMetrics(res); + + mShouldShowLxxSuggestionUi = Settings.SHOULD_SHOW_LXX_SUGGESTION_UI + && prefs.getBoolean(DebugSettings.PREF_SHOULD_SHOW_LXX_SUGGESTION_UI, true); + // Compute other readable settings + mKeyLongpressTimeout = Settings.readKeyLongpressTimeout(prefs, res); + mKeypressVibrationDuration = Settings.readKeypressVibrationDuration(prefs, res); + mKeypressSoundVolume = Settings.readKeypressSoundVolume(prefs, res); + mKeyPreviewPopupDismissDelay = Settings.readKeyPreviewPopupDismissDelay(prefs, res); + mEnableEmojiAltPhysicalKey = prefs.getBoolean( + Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY, true); + mShowAppIcon = Settings.readShowSetupWizardIcon(prefs, context); + mIsShowAppIconSettingInPreferences = prefs.contains(Settings.PREF_SHOW_SETUP_WIZARD_ICON); + mAutoCorrectionThreshold = readAutoCorrectionThreshold(res, + autoCorrectionThresholdRawValue); + mPlausibilityThreshold = Settings.readPlausibilityThreshold(res); + mGestureInputEnabled = Settings.readGestureInputEnabled(prefs, res); + mGestureTrailEnabled = prefs.getBoolean(Settings.PREF_GESTURE_PREVIEW_TRAIL, true); + mCloudSyncEnabled = prefs.getBoolean(LocalSettingsConstants.PREF_ENABLE_CLOUD_SYNC, false); + mAccount = prefs.getString(LocalSettingsConstants.PREF_ACCOUNT_NAME, + null /* default */); + mGestureFloatingPreviewTextEnabled = !mInputAttributes.mDisableGestureFloatingPreviewText + && prefs.getBoolean(Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT, true); + mAutoCorrectionEnabledPerUserSettings = mAutoCorrectEnabled + && !mInputAttributes.mInputTypeNoAutoCorrect; + mSuggestionsEnabledPerUserSettings = readSuggestionsEnabled(prefs); + mIsInternal = Settings.isInternal(prefs); + mHasCustomKeyPreviewAnimationParams = prefs.getBoolean( + DebugSettings.PREF_HAS_CUSTOM_KEY_PREVIEW_ANIMATION_PARAMS, false); + mHasKeyboardResize = prefs.getBoolean(DebugSettings.PREF_RESIZE_KEYBOARD, false); + mKeyboardHeightScale = Settings.readKeyboardHeight(prefs, DEFAULT_SIZE_SCALE); + mKeyPreviewShowUpDuration = Settings.readKeyPreviewAnimationDuration( + prefs, DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_DURATION, + res.getInteger(R.integer.config_key_preview_show_up_duration)); + mKeyPreviewDismissDuration = Settings.readKeyPreviewAnimationDuration( + prefs, DebugSettings.PREF_KEY_PREVIEW_DISMISS_DURATION, + res.getInteger(R.integer.config_key_preview_dismiss_duration)); + final float defaultKeyPreviewShowUpStartScale = ResourceUtils.getFloatFromFraction( + res, R.fraction.config_key_preview_show_up_start_scale); + final float defaultKeyPreviewDismissEndScale = ResourceUtils.getFloatFromFraction( + res, R.fraction.config_key_preview_dismiss_end_scale); + mKeyPreviewShowUpStartXScale = Settings.readKeyPreviewAnimationScale( + prefs, DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_X_SCALE, + defaultKeyPreviewShowUpStartScale); + mKeyPreviewShowUpStartYScale = Settings.readKeyPreviewAnimationScale( + prefs, DebugSettings.PREF_KEY_PREVIEW_SHOW_UP_START_Y_SCALE, + defaultKeyPreviewShowUpStartScale); + mKeyPreviewDismissEndXScale = Settings.readKeyPreviewAnimationScale( + prefs, DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_X_SCALE, + defaultKeyPreviewDismissEndScale); + mKeyPreviewDismissEndYScale = Settings.readKeyPreviewAnimationScale( + prefs, DebugSettings.PREF_KEY_PREVIEW_DISMISS_END_Y_SCALE, + defaultKeyPreviewDismissEndScale); + mDisplayOrientation = res.getConfiguration().orientation; + mAppWorkarounds = new AsyncResultHolder<>("AppWorkarounds"); + final PackageInfo packageInfo = TargetPackageInfoGetterTask.getCachedPackageInfo( + mInputAttributes.mTargetApplicationPackageName); + if (null != packageInfo) { + mAppWorkarounds.set(new AppWorkaroundsUtils(packageInfo)); + } else { + new TargetPackageInfoGetterTask(context, mAppWorkarounds) + .execute(mInputAttributes.mTargetApplicationPackageName); + } + } + + public boolean isMetricsLoggingEnabled() { + return mEnableMetricsLogging; + } + + public boolean isApplicationSpecifiedCompletionsOn() { + return mInputAttributes.mApplicationSpecifiedCompletionOn; + } + + public boolean needsToLookupSuggestions() { + return mInputAttributes.mShouldShowSuggestions + && (mAutoCorrectionEnabledPerUserSettings || isSuggestionsEnabledPerUserSettings()); + } + + public boolean isSuggestionsEnabledPerUserSettings() { + return mSuggestionsEnabledPerUserSettings; + } + + public boolean isPersonalizationEnabled() { + return mUsePersonalizedDicts; + } + + public boolean isWordSeparator(final int code) { + return mSpacingAndPunctuations.isWordSeparator(code); + } + + public boolean isWordConnector(final int code) { + return mSpacingAndPunctuations.isWordConnector(code); + } + + public boolean isWordCodePoint(final int code) { + return Character.isLetter(code) || isWordConnector(code) + || Character.COMBINING_SPACING_MARK == Character.getType(code); + } + + public boolean isUsuallyPrecededBySpace(final int code) { + return mSpacingAndPunctuations.isUsuallyPrecededBySpace(code); + } + + public boolean isUsuallyFollowedBySpace(final int code) { + return mSpacingAndPunctuations.isUsuallyFollowedBySpace(code); + } + + public boolean shouldInsertSpacesAutomatically() { + return mInputAttributes.mShouldInsertSpacesAutomatically; + } + + public boolean isLanguageSwitchKeyEnabled() { + if (!mShowsLanguageSwitchKey) { + return false; + } + final RichInputMethodManager imm = RichInputMethodManager.getInstance(); + if (mIncludesOtherImesInLanguageSwitchList) { + return imm.hasMultipleEnabledIMEsOrSubtypes(false /* include aux subtypes */); + } + return imm.hasMultipleEnabledSubtypesInThisIme(false /* include aux subtypes */); + } + + public boolean isSameInputType(final EditorInfo editorInfo) { + return mInputAttributes.isSameInputType(editorInfo); + } + + public boolean hasSameOrientation(final Configuration configuration) { + return mDisplayOrientation == configuration.orientation; + } + + public boolean isBeforeJellyBean() { + final AppWorkaroundsUtils appWorkaroundUtils + = mAppWorkarounds.get(null, TIMEOUT_TO_GET_TARGET_PACKAGE); + return null == appWorkaroundUtils ? false : appWorkaroundUtils.isBeforeJellyBean(); + } + + public boolean isBrokenByRecorrection() { + final AppWorkaroundsUtils appWorkaroundUtils + = mAppWorkarounds.get(null, TIMEOUT_TO_GET_TARGET_PACKAGE); + return null == appWorkaroundUtils ? false : appWorkaroundUtils.isBrokenByRecorrection(); + } + + private static final String SUGGESTIONS_VISIBILITY_HIDE_VALUE_OBSOLETE = "2"; + + private static boolean readSuggestionsEnabled(final SharedPreferences prefs) { + if (prefs.contains(Settings.PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE)) { + final boolean alwaysHide = SUGGESTIONS_VISIBILITY_HIDE_VALUE_OBSOLETE.equals( + prefs.getString(Settings.PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE, null)); + prefs.edit() + .remove(Settings.PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE) + .putBoolean(Settings.PREF_SHOW_SUGGESTIONS, !alwaysHide) + .apply(); + } + return prefs.getBoolean(Settings.PREF_SHOW_SUGGESTIONS, true); + } + + private static boolean readBigramPredictionEnabled(final SharedPreferences prefs, + final Resources res) { + return prefs.getBoolean(Settings.PREF_BIGRAM_PREDICTIONS, res.getBoolean( + R.bool.config_default_next_word_prediction)); + } + + private static float readAutoCorrectionThreshold(final Resources res, + final String currentAutoCorrectionSetting) { + final String[] autoCorrectionThresholdValues = res.getStringArray( + R.array.auto_correction_threshold_values); + // When autoCorrectionThreshold is greater than 1.0, it's like auto correction is off. + final float autoCorrectionThreshold; + try { + final int arrayIndex = Integer.parseInt(currentAutoCorrectionSetting); + if (arrayIndex >= 0 && arrayIndex < autoCorrectionThresholdValues.length) { + final String val = autoCorrectionThresholdValues[arrayIndex]; + if (FLOAT_MAX_VALUE_MARKER_STRING.equals(val)) { + autoCorrectionThreshold = Float.MAX_VALUE; + } else if (FLOAT_NEGATIVE_INFINITY_MARKER_STRING.equals(val)) { + autoCorrectionThreshold = Float.NEGATIVE_INFINITY; + } else { + autoCorrectionThreshold = Float.parseFloat(val); + } + } else { + autoCorrectionThreshold = Float.MAX_VALUE; + } + } catch (final NumberFormatException e) { + // Whenever the threshold settings are correct, never come here. + Log.w(TAG, "Cannot load auto correction threshold setting." + + " currentAutoCorrectionSetting: " + currentAutoCorrectionSetting + + ", autoCorrectionThresholdValues: " + + Arrays.toString(autoCorrectionThresholdValues), e); + return Float.MAX_VALUE; + } + return autoCorrectionThreshold; + } + + private static boolean needsToShowVoiceInputKey(final SharedPreferences prefs, + final Resources res) { + // Migrate preference from {@link Settings#PREF_VOICE_MODE_OBSOLETE} to + // {@link Settings#PREF_VOICE_INPUT_KEY}. + if (prefs.contains(Settings.PREF_VOICE_MODE_OBSOLETE)) { + final String voiceModeMain = res.getString(R.string.voice_mode_main); + final String voiceMode = prefs.getString( + Settings.PREF_VOICE_MODE_OBSOLETE, voiceModeMain); + final boolean shouldShowVoiceInputKey = voiceModeMain.equals(voiceMode); + prefs.edit() + .putBoolean(Settings.PREF_VOICE_INPUT_KEY, shouldShowVoiceInputKey) + // Remove the obsolete preference if exists. + .remove(Settings.PREF_VOICE_MODE_OBSOLETE) + .apply(); + } + return prefs.getBoolean(Settings.PREF_VOICE_INPUT_KEY, true); + } + + public String dump() { + final StringBuilder sb = new StringBuilder("Current settings :"); + sb.append("\n mSpacingAndPunctuations = "); + sb.append("" + mSpacingAndPunctuations.dump()); + sb.append("\n mDelayInMillisecondsToUpdateOldSuggestions = "); + sb.append("" + mDelayInMillisecondsToUpdateOldSuggestions); + sb.append("\n mAutoCap = "); + sb.append("" + mAutoCap); + sb.append("\n mVibrateOn = "); + sb.append("" + mVibrateOn); + sb.append("\n mSoundOn = "); + sb.append("" + mSoundOn); + sb.append("\n mKeyPreviewPopupOn = "); + sb.append("" + mKeyPreviewPopupOn); + sb.append("\n mShowsVoiceInputKey = "); + sb.append("" + mShowsVoiceInputKey); + sb.append("\n mIncludesOtherImesInLanguageSwitchList = "); + sb.append("" + mIncludesOtherImesInLanguageSwitchList); + sb.append("\n mShowsLanguageSwitchKey = "); + sb.append("" + mShowsLanguageSwitchKey); + sb.append("\n mUseContactsDict = "); + sb.append("" + mUseContactsDict); + sb.append("\n mUsePersonalizedDicts = "); + sb.append("" + mUsePersonalizedDicts); + sb.append("\n mUseDoubleSpacePeriod = "); + sb.append("" + mUseDoubleSpacePeriod); + sb.append("\n mBlockPotentiallyOffensive = "); + sb.append("" + mBlockPotentiallyOffensive); + sb.append("\n mBigramPredictionEnabled = "); + sb.append("" + mBigramPredictionEnabled); + sb.append("\n mGestureInputEnabled = "); + sb.append("" + mGestureInputEnabled); + sb.append("\n mGestureTrailEnabled = "); + sb.append("" + mGestureTrailEnabled); + sb.append("\n mGestureFloatingPreviewTextEnabled = "); + sb.append("" + mGestureFloatingPreviewTextEnabled); + sb.append("\n mSlidingKeyInputPreviewEnabled = "); + sb.append("" + mSlidingKeyInputPreviewEnabled); + sb.append("\n mKeyLongpressTimeout = "); + sb.append("" + mKeyLongpressTimeout); + sb.append("\n mLocale = "); + sb.append("" + mLocale); + sb.append("\n mInputAttributes = "); + sb.append("" + mInputAttributes); + sb.append("\n mKeypressVibrationDuration = "); + sb.append("" + mKeypressVibrationDuration); + sb.append("\n mKeypressSoundVolume = "); + sb.append("" + mKeypressSoundVolume); + sb.append("\n mKeyPreviewPopupDismissDelay = "); + sb.append("" + mKeyPreviewPopupDismissDelay); + sb.append("\n mAutoCorrectEnabled = "); + sb.append("" + mAutoCorrectEnabled); + sb.append("\n mAutoCorrectionThreshold = "); + sb.append("" + mAutoCorrectionThreshold); + sb.append("\n mAutoCorrectionEnabledPerUserSettings = "); + sb.append("" + mAutoCorrectionEnabledPerUserSettings); + sb.append("\n mSuggestionsEnabledPerUserSettings = "); + sb.append("" + mSuggestionsEnabledPerUserSettings); + sb.append("\n mDisplayOrientation = "); + sb.append("" + mDisplayOrientation); + sb.append("\n mAppWorkarounds = "); + final AppWorkaroundsUtils awu = mAppWorkarounds.get(null, 0); + sb.append("" + (null == awu ? "null" : awu.toString())); + sb.append("\n mIsInternal = "); + sb.append("" + mIsInternal); + sb.append("\n mKeyPreviewShowUpDuration = "); + sb.append("" + mKeyPreviewShowUpDuration); + sb.append("\n mKeyPreviewDismissDuration = "); + sb.append("" + mKeyPreviewDismissDuration); + sb.append("\n mKeyPreviewShowUpStartScaleX = "); + sb.append("" + mKeyPreviewShowUpStartXScale); + sb.append("\n mKeyPreviewShowUpStartScaleY = "); + sb.append("" + mKeyPreviewShowUpStartYScale); + sb.append("\n mKeyPreviewDismissEndScaleX = "); + sb.append("" + mKeyPreviewDismissEndXScale); + sb.append("\n mKeyPreviewDismissEndScaleY = "); + sb.append("" + mKeyPreviewDismissEndYScale); + return sb.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SettingsValuesForSuggestion.java b/java/src/org/kelar/inputmethod/latin/settings/SettingsValuesForSuggestion.java new file mode 100644 index 000000000..b0b3c1d73 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SettingsValuesForSuggestion.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +public class SettingsValuesForSuggestion { + public final boolean mBlockPotentiallyOffensive; + + public SettingsValuesForSuggestion(final boolean blockPotentiallyOffensive) { + mBlockPotentiallyOffensive = blockPotentiallyOffensive; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SpacingAndPunctuations.java b/java/src/org/kelar/inputmethod/latin/settings/SpacingAndPunctuations.java new file mode 100644 index 000000000..0145ead8e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SpacingAndPunctuations.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.res.Resources; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.keyboard.internal.MoreKeySpec; +import org.kelar.inputmethod.latin.PunctuationSuggestions; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.Arrays; +import java.util.Locale; + +public final class SpacingAndPunctuations { + private final int[] mSortedSymbolsPrecededBySpace; + private final int[] mSortedSymbolsFollowedBySpace; + private final int[] mSortedSymbolsClusteringTogether; + private final int[] mSortedWordConnectors; + public final int[] mSortedWordSeparators; + public final PunctuationSuggestions mSuggestPuncList; + private final int mSentenceSeparator; + private final int mAbbreviationMarker; + private final int[] mSortedSentenceTerminators; + public final String mSentenceSeparatorAndSpace; + public final boolean mCurrentLanguageHasSpaces; + public final boolean mUsesAmericanTypography; + public final boolean mUsesGermanRules; + + public SpacingAndPunctuations(final Resources res) { + // To be able to binary search the code point. See {@link #isUsuallyPrecededBySpace(int)}. + mSortedSymbolsPrecededBySpace = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_preceded_by_space)); + // To be able to binary search the code point. See {@link #isUsuallyFollowedBySpace(int)}. + mSortedSymbolsFollowedBySpace = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_followed_by_space)); + mSortedSymbolsClusteringTogether = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_clustering_together)); + // To be able to binary search the code point. See {@link #isWordConnector(int)}. + mSortedWordConnectors = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_word_connectors)); + mSortedWordSeparators = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_word_separators)); + mSortedSentenceTerminators = StringUtils.toSortedCodePointArray( + res.getString(R.string.symbols_sentence_terminators)); + mSentenceSeparator = res.getInteger(R.integer.sentence_separator); + mAbbreviationMarker = res.getInteger(R.integer.abbreviation_marker); + mSentenceSeparatorAndSpace = new String(new int[] { + mSentenceSeparator, Constants.CODE_SPACE }, 0, 2); + mCurrentLanguageHasSpaces = res.getBoolean(R.bool.current_language_has_spaces); + final Locale locale = res.getConfiguration().locale; + // Heuristic: we use American Typography rules because it's the most common rules for all + // English variants. German rules (not "German typography") also have small gotchas. + mUsesAmericanTypography = Locale.ENGLISH.getLanguage().equals(locale.getLanguage()); + mUsesGermanRules = Locale.GERMAN.getLanguage().equals(locale.getLanguage()); + final String[] suggestPuncsSpec = MoreKeySpec.splitKeySpecs( + res.getString(R.string.suggested_punctuations)); + mSuggestPuncList = PunctuationSuggestions.newPunctuationSuggestions(suggestPuncsSpec); + } + + @UsedForTesting + public SpacingAndPunctuations(final SpacingAndPunctuations model, + final int[] overrideSortedWordSeparators) { + mSortedSymbolsPrecededBySpace = model.mSortedSymbolsPrecededBySpace; + mSortedSymbolsFollowedBySpace = model.mSortedSymbolsFollowedBySpace; + mSortedSymbolsClusteringTogether = model.mSortedSymbolsClusteringTogether; + mSortedWordConnectors = model.mSortedWordConnectors; + mSortedWordSeparators = overrideSortedWordSeparators; + mSortedSentenceTerminators = model.mSortedSentenceTerminators; + mSuggestPuncList = model.mSuggestPuncList; + mSentenceSeparator = model.mSentenceSeparator; + mAbbreviationMarker = model.mAbbreviationMarker; + mSentenceSeparatorAndSpace = model.mSentenceSeparatorAndSpace; + mCurrentLanguageHasSpaces = model.mCurrentLanguageHasSpaces; + mUsesAmericanTypography = model.mUsesAmericanTypography; + mUsesGermanRules = model.mUsesGermanRules; + } + + public boolean isWordSeparator(final int code) { + return Arrays.binarySearch(mSortedWordSeparators, code) >= 0; + } + + public boolean isWordConnector(final int code) { + return Arrays.binarySearch(mSortedWordConnectors, code) >= 0; + } + + public boolean isWordCodePoint(final int code) { + return Character.isLetter(code) || isWordConnector(code); + } + + public boolean isUsuallyPrecededBySpace(final int code) { + return Arrays.binarySearch(mSortedSymbolsPrecededBySpace, code) >= 0; + } + + public boolean isUsuallyFollowedBySpace(final int code) { + return Arrays.binarySearch(mSortedSymbolsFollowedBySpace, code) >= 0; + } + + public boolean isClusteringSymbol(final int code) { + return Arrays.binarySearch(mSortedSymbolsClusteringTogether, code) >= 0; + } + + public boolean isSentenceTerminator(final int code) { + return Arrays.binarySearch(mSortedSentenceTerminators, code) >= 0; + } + + public boolean isAbbreviationMarker(final int code) { + return code == mAbbreviationMarker; + } + + public boolean isSentenceSeparator(final int code) { + return code == mSentenceSeparator; + } + + public String dump() { + final StringBuilder sb = new StringBuilder(); + sb.append("mSortedSymbolsPrecededBySpace = "); + sb.append("" + Arrays.toString(mSortedSymbolsPrecededBySpace)); + sb.append("\n mSortedSymbolsFollowedBySpace = "); + sb.append("" + Arrays.toString(mSortedSymbolsFollowedBySpace)); + sb.append("\n mSortedWordConnectors = "); + sb.append("" + Arrays.toString(mSortedWordConnectors)); + sb.append("\n mSortedWordSeparators = "); + sb.append("" + Arrays.toString(mSortedWordSeparators)); + sb.append("\n mSuggestPuncList = "); + sb.append("" + mSuggestPuncList); + sb.append("\n mSentenceSeparator = "); + sb.append("" + mSentenceSeparator); + sb.append("\n mSentenceSeparatorAndSpace = "); + sb.append("" + mSentenceSeparatorAndSpace); + sb.append("\n mCurrentLanguageHasSpaces = "); + sb.append("" + mCurrentLanguageHasSpaces); + sb.append("\n mUsesAmericanTypography = "); + sb.append("" + mUsesAmericanTypography); + sb.append("\n mUsesGermanRules = "); + sb.append("" + mUsesGermanRules); + return sb.toString(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/SubScreenFragment.java b/java/src/org/kelar/inputmethod/latin/settings/SubScreenFragment.java new file mode 100644 index 000000000..08c9bd441 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/SubScreenFragment.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.app.backup.BackupManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.content.res.Resources; +import android.os.Bundle; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.util.Log; + +/** + * A base abstract class for a {@link PreferenceFragment} that implements a nested + * {@link PreferenceScreen} of the main preference screen. + */ +public abstract class SubScreenFragment extends PreferenceFragment + implements OnSharedPreferenceChangeListener { + private OnSharedPreferenceChangeListener mSharedPreferenceChangeListener; + + static void setPreferenceEnabled(final String prefKey, final boolean enabled, + final PreferenceScreen screen) { + final Preference preference = screen.findPreference(prefKey); + if (preference != null) { + preference.setEnabled(enabled); + } + } + + static void removePreference(final String prefKey, final PreferenceScreen screen) { + final Preference preference = screen.findPreference(prefKey); + if (preference != null) { + screen.removePreference(preference); + } + } + + static void updateListPreferenceSummaryToCurrentValue(final String prefKey, + final PreferenceScreen screen) { + // Because the "%s" summary trick of {@link ListPreference} doesn't work properly before + // KitKat, we need to update the summary programmatically. + final ListPreference listPreference = (ListPreference)screen.findPreference(prefKey); + if (listPreference == null) { + return; + } + final CharSequence entries[] = listPreference.getEntries(); + final int entryIndex = listPreference.findIndexOfValue(listPreference.getValue()); + listPreference.setSummary(entryIndex < 0 ? null : entries[entryIndex]); + } + + final void setPreferenceEnabled(final String prefKey, final boolean enabled) { + setPreferenceEnabled(prefKey, enabled, getPreferenceScreen()); + } + + final void removePreference(final String prefKey) { + removePreference(prefKey, getPreferenceScreen()); + } + + final void updateListPreferenceSummaryToCurrentValue(final String prefKey) { + updateListPreferenceSummaryToCurrentValue(prefKey, getPreferenceScreen()); + } + + final SharedPreferences getSharedPreferences() { + return getPreferenceManager().getSharedPreferences(); + } + + /** + * Gets the application name to display on the UI. + */ + final String getApplicationName() { + final Context context = getActivity(); + final Resources res = getResources(); + final int applicationLabelRes = context.getApplicationInfo().labelRes; + return res.getString(applicationLabelRes); + } + + @Override + public void addPreferencesFromResource(final int preferencesResId) { + super.addPreferencesFromResource(preferencesResId); + TwoStatePreferenceHelper.replaceCheckBoxPreferencesBySwitchPreferences( + getPreferenceScreen()); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mSharedPreferenceChangeListener = new OnSharedPreferenceChangeListener() { + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + final SubScreenFragment fragment = SubScreenFragment.this; + final Context context = fragment.getActivity(); + if (context == null || fragment.getPreferenceScreen() == null) { + final String tag = fragment.getClass().getSimpleName(); + // TODO: Introduce a static function to register this class and ensure that + // onCreate must be called before "onSharedPreferenceChanged" is called. + Log.w(tag, "onSharedPreferenceChanged called before activity starts."); + return; + } + new BackupManager(context).dataChanged(); + fragment.onSharedPreferenceChanged(prefs, key); + } + }; + getSharedPreferences().registerOnSharedPreferenceChangeListener( + mSharedPreferenceChangeListener); + } + + @Override + public void onDestroy() { + getSharedPreferences().unregisterOnSharedPreferenceChangeListener( + mSharedPreferenceChangeListener); + super.onDestroy(); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + // This method may be overridden by an extended class. + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/TestFragmentActivity.java b/java/src/org/kelar/inputmethod/latin/settings/TestFragmentActivity.java new file mode 100644 index 000000000..c235dc8f5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/TestFragmentActivity.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Intent; +import android.os.Bundle; + +/** + * Test activity to use when testing preference fragments. <br/> + * Usage: <br/> + * Create an ActivityInstrumentationTestCase2 for this activity + * and call setIntent() with an intent that specifies the fragment to load in the activity. + * The fragment can then be obtained from this activity and used for testing/verification. + */ +public final class TestFragmentActivity extends Activity { + /** + * The fragment name that should be loaded when starting this activity. + * This must be specified when starting this activity, as this activity is only + * meant to test fragments from instrumentation tests. + */ + public static final String EXTRA_SHOW_FRAGMENT = "show_fragment"; + + public Fragment mFragment; + + @Override + protected void onCreate(final Bundle savedState) { + super.onCreate(savedState); + final Intent intent = getIntent(); + final String fragmentName = intent.getStringExtra(EXTRA_SHOW_FRAGMENT); + if (fragmentName == null) { + throw new IllegalArgumentException("No fragment name specified for testing"); + } + + mFragment = Fragment.instantiate(this, fragmentName); + FragmentManager fragmentManager = getFragmentManager(); + fragmentManager.beginTransaction().add(mFragment, fragmentName).commit(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/ThemeSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/settings/ThemeSettingsFragment.java new file mode 100644 index 000000000..f0d51196c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/ThemeSettingsFragment.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceScreen; + +import org.kelar.inputmethod.keyboard.KeyboardTheme; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.settings.RadioButtonPreference.OnRadioButtonClickedListener; + +/** + * "Keyboard theme" settings sub screen. + */ +public final class ThemeSettingsFragment extends SubScreenFragment + implements OnRadioButtonClickedListener { + private int mSelectedThemeId; + + static class KeyboardThemePreference extends RadioButtonPreference { + final int mThemeId; + + KeyboardThemePreference(final Context context, final String name, final int id) { + super(context); + setTitle(name); + mThemeId = id; + } + } + + static void updateKeyboardThemeSummary(final Preference pref) { + final Context context = pref.getContext(); + final Resources res = context.getResources(); + final KeyboardTheme keyboardTheme = KeyboardTheme.getKeyboardTheme(context); + final String[] keyboardThemeNames = res.getStringArray(R.array.keyboard_theme_names); + final int[] keyboardThemeIds = res.getIntArray(R.array.keyboard_theme_ids); + for (int index = 0; index < keyboardThemeNames.length; index++) { + if (keyboardTheme.mThemeId == keyboardThemeIds[index]) { + pref.setSummary(keyboardThemeNames[index]); + return; + } + } + } + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + addPreferencesFromResource(R.xml.prefs_screen_theme); + final PreferenceScreen screen = getPreferenceScreen(); + final Context context = getActivity(); + final Resources res = getResources(); + final String[] keyboardThemeNames = res.getStringArray(R.array.keyboard_theme_names); + final int[] keyboardThemeIds = res.getIntArray(R.array.keyboard_theme_ids); + for (int index = 0; index < keyboardThemeNames.length; index++) { + final KeyboardThemePreference pref = new KeyboardThemePreference( + context, keyboardThemeNames[index], keyboardThemeIds[index]); + screen.addPreference(pref); + pref.setOnRadioButtonClickedListener(this); + } + final KeyboardTheme keyboardTheme = KeyboardTheme.getKeyboardTheme(context); + mSelectedThemeId = keyboardTheme.mThemeId; + } + + @Override + public void onRadioButtonClicked(final RadioButtonPreference preference) { + if (preference instanceof KeyboardThemePreference) { + final KeyboardThemePreference pref = (KeyboardThemePreference)preference; + mSelectedThemeId = pref.mThemeId; + updateSelected(); + } + } + + @Override + public void onResume() { + super.onResume(); + updateSelected(); + } + + @Override + public void onPause() { + super.onPause(); + KeyboardTheme.saveKeyboardThemeId(mSelectedThemeId, getSharedPreferences()); + } + + private void updateSelected() { + final PreferenceScreen screen = getPreferenceScreen(); + final int count = screen.getPreferenceCount(); + for (int index = 0; index < count; index++) { + final Preference preference = screen.getPreference(index); + if (preference instanceof KeyboardThemePreference) { + final KeyboardThemePreference pref = (KeyboardThemePreference)preference; + final boolean selected = (mSelectedThemeId == pref.mThemeId); + pref.setSelected(selected); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/settings/TwoStatePreferenceHelper.java b/java/src/org/kelar/inputmethod/latin/settings/TwoStatePreferenceHelper.java new file mode 100644 index 000000000..7657a4022 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/settings/TwoStatePreferenceHelper.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.settings; + +import android.os.Build; +import android.preference.CheckBoxPreference; +import android.preference.Preference; +import android.preference.PreferenceGroup; +import android.preference.SwitchPreference; + +import java.util.ArrayList; + +public class TwoStatePreferenceHelper { + private static final String EMPTY_TEXT = ""; + + private TwoStatePreferenceHelper() { + // This utility class is not publicly instantiable. + } + + public static void replaceCheckBoxPreferencesBySwitchPreferences(final PreferenceGroup group) { + // The keyboard settings keeps using a CheckBoxPreference on KitKat or previous. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + return; + } + // The keyboard settings starts using a SwitchPreference without switch on/off text on + // API versions newer than KitKat. + replaceAllCheckBoxPreferencesBySwitchPreferences(group); + } + + private static void replaceAllCheckBoxPreferencesBySwitchPreferences( + final PreferenceGroup group) { + final ArrayList<Preference> preferences = new ArrayList<>(); + final int count = group.getPreferenceCount(); + for (int index = 0; index < count; index++) { + preferences.add(group.getPreference(index)); + } + group.removeAll(); + for (int index = 0; index < count; index++) { + final Preference preference = preferences.get(index); + if (preference instanceof CheckBoxPreference) { + addSwitchPreferenceBasedOnCheckBoxPreference((CheckBoxPreference)preference, group); + } else { + group.addPreference(preference); + if (preference instanceof PreferenceGroup) { + replaceAllCheckBoxPreferencesBySwitchPreferences((PreferenceGroup)preference); + } + } + } + } + + static void addSwitchPreferenceBasedOnCheckBoxPreference(final CheckBoxPreference checkBox, + final PreferenceGroup group) { + final SwitchPreference switchPref = new SwitchPreference(checkBox.getContext()); + switchPref.setTitle(checkBox.getTitle()); + switchPref.setKey(checkBox.getKey()); + switchPref.setOrder(checkBox.getOrder()); + switchPref.setPersistent(checkBox.isPersistent()); + switchPref.setEnabled(checkBox.isEnabled()); + switchPref.setChecked(checkBox.isChecked()); + switchPref.setSummary(checkBox.getSummary()); + switchPref.setSummaryOn(checkBox.getSummaryOn()); + switchPref.setSummaryOff(checkBox.getSummaryOff()); + switchPref.setSwitchTextOn(EMPTY_TEXT); + switchPref.setSwitchTextOff(EMPTY_TEXT); + group.addPreference(switchPref); + switchPref.setDependency(checkBox.getDependency()); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/setup/SetupActivity.java b/java/src/org/kelar/inputmethod/latin/setup/SetupActivity.java new file mode 100644 index 000000000..55b616605 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/setup/SetupActivity.java @@ -0,0 +1,36 @@ +/* + * 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 org.kelar.inputmethod.latin.setup; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +public final class SetupActivity extends Activity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = new Intent(); + intent.setClass(this, SetupWizardActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + if (!isFinishing()) { + finish(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/setup/SetupStartIndicatorView.java b/java/src/org/kelar/inputmethod/latin/setup/SetupStartIndicatorView.java new file mode 100644 index 000000000..aa80e4ce3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/setup/SetupStartIndicatorView.java @@ -0,0 +1,123 @@ +/* + * 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 org.kelar.inputmethod.latin.setup; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import androidx.core.view.ViewCompat; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.kelar.inputmethod.latin.R; + +public final class SetupStartIndicatorView extends LinearLayout { + public SetupStartIndicatorView(final Context context, final AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + LayoutInflater.from(context).inflate(R.layout.setup_start_indicator_label, this); + + final LabelView labelView = (LabelView)findViewById(R.id.setup_start_label); + labelView.setIndicatorView(findViewById(R.id.setup_start_indicator)); + } + + public static final class LabelView extends TextView { + private View mIndicatorView; + + public LabelView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public void setIndicatorView(final View indicatorView) { + mIndicatorView = indicatorView; + } + + // TODO: Once we stop supporting ICS, uncomment {@link #setPressed(boolean)} method and + // remove this method. + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + for (final int state : getDrawableState()) { + if (state == android.R.attr.state_pressed) { + updateIndicatorView(true /* pressed */); + return; + } + } + updateIndicatorView(false /* pressed */); + } + + // TODO: Once we stop supporting ICS, uncomment this method and remove + // {@link #drawableStateChanged()} method. +// @Override +// public void setPressed(final boolean pressed) { +// super.setPressed(pressed); +// updateIndicatorView(pressed); +// } + + private void updateIndicatorView(final boolean pressed) { + if (mIndicatorView != null) { + mIndicatorView.setPressed(pressed); + mIndicatorView.invalidate(); + } + } + } + + public static final class IndicatorView extends View { + private final Path mIndicatorPath = new Path(); + private final Paint mIndicatorPaint = new Paint(); + private final ColorStateList mIndicatorColor; + + public IndicatorView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mIndicatorColor = getResources().getColorStateList( + R.color.setup_step_action_background); + mIndicatorPaint.setStyle(Paint.Style.FILL); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + final int layoutDirection = ViewCompat.getLayoutDirection(this); + final int width = getWidth(); + final int height = getHeight(); + final float halfHeight = height / 2.0f; + final Path path = mIndicatorPath; + path.rewind(); + if (layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { + // Left arrow + path.moveTo(width, 0.0f); + path.lineTo(0.0f, halfHeight); + path.lineTo(width, height); + } else { // LAYOUT_DIRECTION_LTR + // Right arrow + path.moveTo(0.0f, 0.0f); + path.lineTo(width, halfHeight); + path.lineTo(0.0f, height); + } + path.close(); + final int[] stateSet = getDrawableState(); + final int color = mIndicatorColor.getColorForState(stateSet, 0); + mIndicatorPaint.setColor(color); + canvas.drawPath(path, mIndicatorPaint); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/setup/SetupStepIndicatorView.java b/java/src/org/kelar/inputmethod/latin/setup/SetupStepIndicatorView.java new file mode 100644 index 000000000..919eef571 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/setup/SetupStepIndicatorView.java @@ -0,0 +1,62 @@ +/* + * 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 org.kelar.inputmethod.latin.setup; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import androidx.core.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; + +import org.kelar.inputmethod.latin.R; + +public final class SetupStepIndicatorView extends View { + private final Path mIndicatorPath = new Path(); + private final Paint mIndicatorPaint = new Paint(); + private float mXRatio; + + public SetupStepIndicatorView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mIndicatorPaint.setColor(getResources().getColor(R.color.setup_step_background)); + mIndicatorPaint.setStyle(Paint.Style.FILL); + } + + public void setIndicatorPosition(final int stepPos, final int totalStepNum) { + final int layoutDirection = ViewCompat.getLayoutDirection(this); + // The indicator position is the center of the partition that is equally divided into + // the total step number. + final float partionWidth = 1.0f / totalStepNum; + final float pos = stepPos * partionWidth + partionWidth / 2.0f; + mXRatio = (layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) ? 1.0f - pos : pos; + invalidate(); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + final int xPos = (int)(getWidth() * mXRatio); + final int height = getHeight(); + mIndicatorPath.rewind(); + mIndicatorPath.moveTo(xPos, 0); + mIndicatorPath.lineTo(xPos + height, height); + mIndicatorPath.lineTo(xPos - height, height); + mIndicatorPath.close(); + canvas.drawPath(mIndicatorPath, mIndicatorPaint); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/setup/SetupWizardActivity.java b/java/src/org/kelar/inputmethod/latin/setup/SetupWizardActivity.java new file mode 100644 index 000000000..099c7a023 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/setup/SetupWizardActivity.java @@ -0,0 +1,513 @@ +/* + * 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 org.kelar.inputmethod.latin.setup; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.res.Resources; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Bundle; +import android.os.Message; +import android.provider.Settings; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.VideoView; + +import org.kelar.inputmethod.compat.TextViewCompatUtils; +import org.kelar.inputmethod.compat.ViewCompatUtils; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.settings.SettingsActivity; +import org.kelar.inputmethod.latin.utils.LeakGuardHandlerWrapper; +import org.kelar.inputmethod.latin.utils.UncachedInputMethodManagerUtils; + +import java.util.ArrayList; + +import javax.annotation.Nonnull; + +// TODO: Use Fragment to implement welcome screen and setup steps. +public final class SetupWizardActivity extends Activity implements View.OnClickListener { + static final String TAG = SetupWizardActivity.class.getSimpleName(); + + // For debugging purpose. + private static final boolean FORCE_TO_SHOW_WELCOME_SCREEN = false; + private static final boolean ENABLE_WELCOME_VIDEO = true; + + private InputMethodManager mImm; + + private View mSetupWizard; + private View mWelcomeScreen; + private View mSetupScreen; + private Uri mWelcomeVideoUri; + private VideoView mWelcomeVideoView; + private ImageView mWelcomeImageView; + private View mActionStart; + private View mActionNext; + private TextView mStep1Bullet; + private TextView mActionFinish; + private SetupStepGroup mSetupStepGroup; + private static final String STATE_STEP = "step"; + private int mStepNumber; + private boolean mNeedsToAdjustStepNumberToSystemState; + private static final int STEP_WELCOME = 0; + private static final int STEP_1 = 1; + private static final int STEP_2 = 2; + private static final int STEP_3 = 3; + private static final int STEP_LAUNCHING_IME_SETTINGS = 4; + private static final int STEP_BACK_FROM_IME_SETTINGS = 5; + + private SettingsPoolingHandler mHandler; + + private static final class SettingsPoolingHandler + extends LeakGuardHandlerWrapper<SetupWizardActivity> { + private static final int MSG_POLLING_IME_SETTINGS = 0; + private static final long IME_SETTINGS_POLLING_INTERVAL = 200; + + private final InputMethodManager mImmInHandler; + + public SettingsPoolingHandler(@Nonnull final SetupWizardActivity ownerInstance, + final InputMethodManager imm) { + super(ownerInstance); + mImmInHandler = imm; + } + + @Override + public void handleMessage(final Message msg) { + final SetupWizardActivity setupWizardActivity = getOwnerInstance(); + if (setupWizardActivity == null) { + return; + } + switch (msg.what) { + case MSG_POLLING_IME_SETTINGS: + if (UncachedInputMethodManagerUtils.isThisImeEnabled(setupWizardActivity, + mImmInHandler)) { + setupWizardActivity.invokeSetupWizardOfThisIme(); + return; + } + startPollingImeSettings(); + break; + } + } + + public void startPollingImeSettings() { + sendMessageDelayed(obtainMessage(MSG_POLLING_IME_SETTINGS), + IME_SETTINGS_POLLING_INTERVAL); + } + + public void cancelPollingImeSettings() { + removeMessages(MSG_POLLING_IME_SETTINGS); + } + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + setTheme(android.R.style.Theme_Translucent_NoTitleBar); + super.onCreate(savedInstanceState); + + mImm = (InputMethodManager)getSystemService(INPUT_METHOD_SERVICE); + mHandler = new SettingsPoolingHandler(this, mImm); + + setContentView(R.layout.setup_wizard); + mSetupWizard = findViewById(R.id.setup_wizard); + + if (savedInstanceState == null) { + mStepNumber = determineSetupStepNumberFromLauncher(); + } else { + mStepNumber = savedInstanceState.getInt(STATE_STEP); + } + + final String applicationName = getResources().getString(getApplicationInfo().labelRes); + mWelcomeScreen = findViewById(R.id.setup_welcome_screen); + final TextView welcomeTitle = (TextView)findViewById(R.id.setup_welcome_title); + welcomeTitle.setText(getString(R.string.setup_welcome_title, applicationName)); + + mSetupScreen = findViewById(R.id.setup_steps_screen); + final TextView stepsTitle = (TextView)findViewById(R.id.setup_title); + stepsTitle.setText(getString(R.string.setup_steps_title, applicationName)); + + final SetupStepIndicatorView indicatorView = + (SetupStepIndicatorView)findViewById(R.id.setup_step_indicator); + mSetupStepGroup = new SetupStepGroup(indicatorView); + + mStep1Bullet = (TextView)findViewById(R.id.setup_step1_bullet); + mStep1Bullet.setOnClickListener(this); + final SetupStep step1 = new SetupStep(STEP_1, applicationName, + mStep1Bullet, findViewById(R.id.setup_step1), + R.string.setup_step1_title, R.string.setup_step1_instruction, + R.string.setup_step1_finished_instruction, R.drawable.ic_setup_step1, + R.string.setup_step1_action); + final SettingsPoolingHandler handler = mHandler; + step1.setAction(new Runnable() { + @Override + public void run() { + invokeLanguageAndInputSettings(); + handler.startPollingImeSettings(); + } + }); + mSetupStepGroup.addStep(step1); + + final SetupStep step2 = new SetupStep(STEP_2, applicationName, + (TextView)findViewById(R.id.setup_step2_bullet), findViewById(R.id.setup_step2), + R.string.setup_step2_title, R.string.setup_step2_instruction, + 0 /* finishedInstruction */, R.drawable.ic_setup_step2, + R.string.setup_step2_action); + step2.setAction(new Runnable() { + @Override + public void run() { + invokeInputMethodPicker(); + } + }); + mSetupStepGroup.addStep(step2); + + final SetupStep step3 = new SetupStep(STEP_3, applicationName, + (TextView)findViewById(R.id.setup_step3_bullet), findViewById(R.id.setup_step3), + R.string.setup_step3_title, R.string.setup_step3_instruction, + 0 /* finishedInstruction */, R.drawable.ic_setup_step3, + R.string.setup_step3_action); + step3.setAction(new Runnable() { + @Override + public void run() { + invokeSubtypeEnablerOfThisIme(); + } + }); + mSetupStepGroup.addStep(step3); + + mWelcomeVideoUri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(getPackageName()) + .path(Integer.toString(R.raw.setup_welcome_video)) + .build(); + final VideoView welcomeVideoView = (VideoView)findViewById(R.id.setup_welcome_video); + welcomeVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(final MediaPlayer mp) { + // Now VideoView has been laid-out and ready to play, remove background of it to + // reveal the video. + welcomeVideoView.setBackgroundResource(0); + mp.setLooping(true); + } + }); + welcomeVideoView.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(final MediaPlayer mp, final int what, final int extra) { + Log.e(TAG, "Playing welcome video causes error: what=" + what + " extra=" + extra); + hideWelcomeVideoAndShowWelcomeImage(); + return true; + } + }); + mWelcomeVideoView = welcomeVideoView; + mWelcomeImageView = (ImageView)findViewById(R.id.setup_welcome_image); + + mActionStart = findViewById(R.id.setup_start_label); + mActionStart.setOnClickListener(this); + mActionNext = findViewById(R.id.setup_next); + mActionNext.setOnClickListener(this); + mActionFinish = (TextView)findViewById(R.id.setup_finish); + TextViewCompatUtils.setCompoundDrawablesRelativeWithIntrinsicBounds(mActionFinish, + getResources().getDrawable(R.drawable.ic_setup_finish), null, null, null); + mActionFinish.setOnClickListener(this); + } + + @Override + public void onClick(final View v) { + if (v == mActionFinish) { + finish(); + return; + } + final int currentStep = determineSetupStepNumber(); + final int nextStep; + if (v == mActionStart) { + nextStep = STEP_1; + } else if (v == mActionNext) { + nextStep = mStepNumber + 1; + } else if (v == mStep1Bullet && currentStep == STEP_2) { + nextStep = STEP_1; + } else { + nextStep = mStepNumber; + } + if (mStepNumber != nextStep) { + mStepNumber = nextStep; + updateSetupStepView(); + } + } + + void invokeSetupWizardOfThisIme() { + final Intent intent = new Intent(); + intent.setClass(this, SetupWizardActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + mNeedsToAdjustStepNumberToSystemState = true; + } + + private void invokeSettingsOfThisIme() { + final Intent intent = new Intent(); + intent.setClass(this, SettingsActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY, + SettingsActivity.EXTRA_ENTRY_VALUE_APP_ICON); + startActivity(intent); + } + + void invokeLanguageAndInputSettings() { + final Intent intent = new Intent(); + intent.setAction(Settings.ACTION_INPUT_METHOD_SETTINGS); + intent.addCategory(Intent.CATEGORY_DEFAULT); + startActivity(intent); + mNeedsToAdjustStepNumberToSystemState = true; + } + + void invokeInputMethodPicker() { + // Invoke input method picker. + mImm.showInputMethodPicker(); + mNeedsToAdjustStepNumberToSystemState = true; + } + + void invokeSubtypeEnablerOfThisIme() { + final InputMethodInfo imi = + UncachedInputMethodManagerUtils.getInputMethodInfoOf(getPackageName(), mImm); + if (imi == null) { + return; + } + final Intent intent = new Intent(); + intent.setAction(Settings.ACTION_INPUT_METHOD_SUBTYPE_SETTINGS); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.putExtra(Settings.EXTRA_INPUT_METHOD_ID, imi.getId()); + startActivity(intent); + } + + private int determineSetupStepNumberFromLauncher() { + final int stepNumber = determineSetupStepNumber(); + if (stepNumber == STEP_1) { + return STEP_WELCOME; + } + if (stepNumber == STEP_3) { + return STEP_LAUNCHING_IME_SETTINGS; + } + return stepNumber; + } + + private int determineSetupStepNumber() { + mHandler.cancelPollingImeSettings(); + if (FORCE_TO_SHOW_WELCOME_SCREEN) { + return STEP_1; + } + if (!UncachedInputMethodManagerUtils.isThisImeEnabled(this, mImm)) { + return STEP_1; + } + if (!UncachedInputMethodManagerUtils.isThisImeCurrent(this, mImm)) { + return STEP_2; + } + return STEP_3; + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(STATE_STEP, mStepNumber); + } + + @Override + protected void onRestoreInstanceState(final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + mStepNumber = savedInstanceState.getInt(STATE_STEP); + } + + private static boolean isInSetupSteps(final int stepNumber) { + return stepNumber >= STEP_1 && stepNumber <= STEP_3; + } + + @Override + protected void onRestart() { + super.onRestart(); + // Probably the setup wizard has been invoked from "Recent" menu. The setup step number + // needs to be adjusted to system state, because the state (IME is enabled and/or current) + // may have been changed. + if (isInSetupSteps(mStepNumber)) { + mStepNumber = determineSetupStepNumber(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (mStepNumber == STEP_LAUNCHING_IME_SETTINGS) { + // Prevent white screen flashing while launching settings activity. + mSetupWizard.setVisibility(View.INVISIBLE); + invokeSettingsOfThisIme(); + mStepNumber = STEP_BACK_FROM_IME_SETTINGS; + return; + } + if (mStepNumber == STEP_BACK_FROM_IME_SETTINGS) { + finish(); + return; + } + updateSetupStepView(); + } + + @Override + public void onBackPressed() { + if (mStepNumber == STEP_1) { + mStepNumber = STEP_WELCOME; + updateSetupStepView(); + return; + } + super.onBackPressed(); + } + + void hideWelcomeVideoAndShowWelcomeImage() { + mWelcomeVideoView.setVisibility(View.GONE); + mWelcomeImageView.setImageResource(R.raw.setup_welcome_image); + mWelcomeImageView.setVisibility(View.VISIBLE); + } + + private void showAndStartWelcomeVideo() { + mWelcomeVideoView.setVisibility(View.VISIBLE); + mWelcomeVideoView.setVideoURI(mWelcomeVideoUri); + mWelcomeVideoView.start(); + } + + private void hideAndStopWelcomeVideo() { + mWelcomeVideoView.stopPlayback(); + mWelcomeVideoView.setVisibility(View.GONE); + } + + @Override + protected void onPause() { + hideAndStopWelcomeVideo(); + super.onPause(); + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus && mNeedsToAdjustStepNumberToSystemState) { + mNeedsToAdjustStepNumberToSystemState = false; + mStepNumber = determineSetupStepNumber(); + updateSetupStepView(); + } + } + + private void updateSetupStepView() { + mSetupWizard.setVisibility(View.VISIBLE); + final boolean welcomeScreen = (mStepNumber == STEP_WELCOME); + mWelcomeScreen.setVisibility(welcomeScreen ? View.VISIBLE : View.GONE); + mSetupScreen.setVisibility(welcomeScreen ? View.GONE : View.VISIBLE); + if (welcomeScreen) { + if (ENABLE_WELCOME_VIDEO) { + showAndStartWelcomeVideo(); + } else { + hideWelcomeVideoAndShowWelcomeImage(); + } + return; + } + hideAndStopWelcomeVideo(); + final boolean isStepActionAlreadyDone = mStepNumber < determineSetupStepNumber(); + mSetupStepGroup.enableStep(mStepNumber, isStepActionAlreadyDone); + mActionNext.setVisibility(isStepActionAlreadyDone ? View.VISIBLE : View.GONE); + mActionFinish.setVisibility((mStepNumber == STEP_3) ? View.VISIBLE : View.GONE); + } + + static final class SetupStep implements View.OnClickListener { + public final int mStepNo; + private final View mStepView; + private final TextView mBulletView; + private final int mActivatedColor; + private final int mDeactivatedColor; + private final String mInstruction; + private final String mFinishedInstruction; + private final TextView mActionLabel; + private Runnable mAction; + + public SetupStep(final int stepNo, final String applicationName, final TextView bulletView, + final View stepView, final int title, final int instruction, + final int finishedInstruction, final int actionIcon, final int actionLabel) { + mStepNo = stepNo; + mStepView = stepView; + mBulletView = bulletView; + final Resources res = stepView.getResources(); + mActivatedColor = res.getColor(R.color.setup_text_action); + mDeactivatedColor = res.getColor(R.color.setup_text_dark); + + final TextView titleView = (TextView)mStepView.findViewById(R.id.setup_step_title); + titleView.setText(res.getString(title, applicationName)); + mInstruction = (instruction == 0) ? null + : res.getString(instruction, applicationName); + mFinishedInstruction = (finishedInstruction == 0) ? null + : res.getString(finishedInstruction, applicationName); + + mActionLabel = (TextView)mStepView.findViewById(R.id.setup_step_action_label); + mActionLabel.setText(res.getString(actionLabel)); + if (actionIcon == 0) { + final int paddingEnd = ViewCompatUtils.getPaddingEnd(mActionLabel); + ViewCompatUtils.setPaddingRelative(mActionLabel, paddingEnd, 0, paddingEnd, 0); + } else { + TextViewCompatUtils.setCompoundDrawablesRelativeWithIntrinsicBounds( + mActionLabel, res.getDrawable(actionIcon), null, null, null); + } + } + + public void setEnabled(final boolean enabled, final boolean isStepActionAlreadyDone) { + mStepView.setVisibility(enabled ? View.VISIBLE : View.GONE); + mBulletView.setTextColor(enabled ? mActivatedColor : mDeactivatedColor); + final TextView instructionView = (TextView)mStepView.findViewById( + R.id.setup_step_instruction); + instructionView.setText(isStepActionAlreadyDone ? mFinishedInstruction : mInstruction); + mActionLabel.setVisibility(isStepActionAlreadyDone ? View.GONE : View.VISIBLE); + } + + public void setAction(final Runnable action) { + mActionLabel.setOnClickListener(this); + mAction = action; + } + + @Override + public void onClick(final View v) { + if (v == mActionLabel && mAction != null) { + mAction.run(); + return; + } + } + } + + static final class SetupStepGroup { + private final SetupStepIndicatorView mIndicatorView; + private final ArrayList<SetupStep> mGroup = new ArrayList<>(); + + public SetupStepGroup(final SetupStepIndicatorView indicatorView) { + mIndicatorView = indicatorView; + } + + public void addStep(final SetupStep step) { + mGroup.add(step); + } + + public void enableStep(final int enableStepNo, final boolean isStepActionAlreadyDone) { + for (final SetupStep step : mGroup) { + step.setEnabled(step.mStepNo == enableStepNo, isStepActionAlreadyDone); + } + mIndicatorView.setIndicatorPosition(enableStepNo - STEP_1, mGroup.size()); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java new file mode 100644 index 000000000..fb53b92d7 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.spellcheck; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.service.textservice.SpellCheckerService; +import android.text.InputType; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodSubtype; +import android.view.textservice.SuggestionsInfo; + +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardId; +import org.kelar.inputmethod.keyboard.KeyboardLayoutSet; +import org.kelar.inputmethod.latin.DictionaryFacilitator; +import org.kelar.inputmethod.latin.DictionaryFacilitatorLruCache; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodSubtype; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.common.ComposedData; +import org.kelar.inputmethod.latin.settings.SettingsValuesForSuggestion; +import org.kelar.inputmethod.latin.utils.AdditionalSubtypeUtils; +import org.kelar.inputmethod.latin.utils.ScriptUtils; +import org.kelar.inputmethod.latin.utils.SuggestionResults; + +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Semaphore; + +import javax.annotation.Nonnull; + +/** + * Service for spell checking, using LatinIME's dictionaries and mechanisms. + */ +public final class AndroidSpellCheckerService extends SpellCheckerService + implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = AndroidSpellCheckerService.class.getSimpleName(); + private static final boolean DEBUG = false; + + public static final String PREF_USE_CONTACTS_KEY = "pref_spellcheck_use_contacts"; + + private static final int SPELLCHECKER_DUMMY_KEYBOARD_WIDTH = 480; + private static final int SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT = 301; + + private static final String DICTIONARY_NAME_PREFIX = "spellcheck_"; + + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private final int MAX_NUM_OF_THREADS_READ_DICTIONARY = 2; + private final Semaphore mSemaphore = new Semaphore(MAX_NUM_OF_THREADS_READ_DICTIONARY, + true /* fair */); + // TODO: Make each spell checker session has its own session id. + private final ConcurrentLinkedQueue<Integer> mSessionIdPool = new ConcurrentLinkedQueue<>(); + + private final DictionaryFacilitatorLruCache mDictionaryFacilitatorCache = + new DictionaryFacilitatorLruCache(this /* context */, DICTIONARY_NAME_PREFIX); + private final ConcurrentHashMap<Locale, Keyboard> mKeyboardCache = new ConcurrentHashMap<>(); + + // The threshold for a suggestion to be considered "recommended". + private float mRecommendedThreshold; + // TODO: make a spell checker option to block offensive words or not + private final SettingsValuesForSuggestion mSettingsValuesForSuggestion = + new SettingsValuesForSuggestion(true /* blockPotentiallyOffensive */); + + public static final String SINGLE_QUOTE = "\u0027"; + public static final String APOSTROPHE = "\u2019"; + + public AndroidSpellCheckerService() { + super(); + for (int i = 0; i < MAX_NUM_OF_THREADS_READ_DICTIONARY; i++) { + mSessionIdPool.add(i); + } + } + + @Override + public void onCreate() { + super.onCreate(); + mRecommendedThreshold = Float.parseFloat( + getString(R.string.spellchecker_recommended_threshold_value)); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + prefs.registerOnSharedPreferenceChangeListener(this); + onSharedPreferenceChanged(prefs, PREF_USE_CONTACTS_KEY); + } + + public float getRecommendedThreshold() { + return mRecommendedThreshold; + } + + private static String getKeyboardLayoutNameForLocale(final Locale locale) { + // See b/19963288. + if (locale.getLanguage().equals("sr")) { + return "south_slavic"; + } + final int script = ScriptUtils.getScriptFromSpellCheckerLocale(locale); + switch (script) { + case ScriptUtils.SCRIPT_LATIN: + return "qwerty"; + case ScriptUtils.SCRIPT_CYRILLIC: + return "east_slavic"; + case ScriptUtils.SCRIPT_GREEK: + return "greek"; + case ScriptUtils.SCRIPT_HEBREW: + return "hebrew"; + default: + throw new RuntimeException("Wrong script supplied: " + script); + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) { + if (!PREF_USE_CONTACTS_KEY.equals(key)) return; + final boolean useContactsDictionary = prefs.getBoolean(PREF_USE_CONTACTS_KEY, true); + mDictionaryFacilitatorCache.setUseContactsDictionary(useContactsDictionary); + } + + @Override + public Session createSession() { + // Should not refer to AndroidSpellCheckerSession directly considering + // that AndroidSpellCheckerSession may be overlaid. + return AndroidSpellCheckerSessionFactory.newInstance(this); + } + + /** + * Returns an empty SuggestionsInfo with flags signaling the word is not in the dictionary. + * @param reportAsTypo whether this should include the flag LOOKS_LIKE_TYPO, for red underline. + * @return the empty SuggestionsInfo with the appropriate flags set. + */ + public static SuggestionsInfo getNotInDictEmptySuggestions(final boolean reportAsTypo) { + return new SuggestionsInfo(reportAsTypo ? SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO : 0, + EMPTY_STRING_ARRAY); + } + + /** + * Returns an empty suggestionInfo with flags signaling the word is in the dictionary. + * @return the empty SuggestionsInfo with the appropriate flags set. + */ + public static SuggestionsInfo getInDictEmptySuggestions() { + return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY, + EMPTY_STRING_ARRAY); + } + + public boolean isValidWord(final Locale locale, final String word) { + mSemaphore.acquireUninterruptibly(); + try { + DictionaryFacilitator dictionaryFacilitatorForLocale = + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitatorForLocale.isValidSpellingWord(word); + } finally { + mSemaphore.release(); + } + } + + public SuggestionResults getSuggestionResults(final Locale locale, + final ComposedData composedData, final NgramContext ngramContext, + @Nonnull final Keyboard keyboard) { + Integer sessionId = null; + mSemaphore.acquireUninterruptibly(); + try { + sessionId = mSessionIdPool.poll(); + DictionaryFacilitator dictionaryFacilitatorForLocale = + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitatorForLocale.getSuggestionResults(composedData, ngramContext, + keyboard, mSettingsValuesForSuggestion, + sessionId, SuggestedWords.INPUT_STYLE_TYPING); + } finally { + if (sessionId != null) { + mSessionIdPool.add(sessionId); + } + mSemaphore.release(); + } + } + + public boolean hasMainDictionaryForLocale(final Locale locale) { + mSemaphore.acquireUninterruptibly(); + try { + final DictionaryFacilitator dictionaryFacilitator = + mDictionaryFacilitatorCache.get(locale); + return dictionaryFacilitator.hasAtLeastOneInitializedMainDictionary(); + } finally { + mSemaphore.release(); + } + } + + @Override + public boolean onUnbind(final Intent intent) { + mSemaphore.acquireUninterruptibly(MAX_NUM_OF_THREADS_READ_DICTIONARY); + try { + mDictionaryFacilitatorCache.closeDictionaries(); + } finally { + mSemaphore.release(MAX_NUM_OF_THREADS_READ_DICTIONARY); + } + mKeyboardCache.clear(); + return false; + } + + public Keyboard getKeyboardForLocale(final Locale locale) { + Keyboard keyboard = mKeyboardCache.get(locale); + if (keyboard == null) { + keyboard = createKeyboardForLocale(locale); + if (keyboard != null) { + mKeyboardCache.put(locale, keyboard); + } + } + return keyboard; + } + + private Keyboard createKeyboardForLocale(final Locale locale) { + final String keyboardLayoutName = getKeyboardLayoutNameForLocale(locale); + final InputMethodSubtype subtype = AdditionalSubtypeUtils.createDummyAdditionalSubtype( + locale.toString(), keyboardLayoutName); + final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype); + return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET); + } + + private KeyboardLayoutSet createKeyboardSetForSpellChecker(final InputMethodSubtype subtype) { + final EditorInfo editorInfo = new EditorInfo(); + editorInfo.inputType = InputType.TYPE_CLASS_TEXT; + final KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(this, editorInfo); + builder.setKeyboardGeometry( + SPELLCHECKER_DUMMY_KEYBOARD_WIDTH, SPELLCHECKER_DUMMY_KEYBOARD_HEIGHT); + builder.setSubtype(RichInputMethodSubtype.getRichInputMethodSubtype(subtype)); + builder.setIsSpellChecker(true /* isSpellChecker */); + builder.disableTouchPositionCorrectionData(); + return builder.build(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java new file mode 100644 index 000000000..3ab5138bf --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java @@ -0,0 +1,225 @@ +/* + * 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 org.kelar.inputmethod.latin.spellcheck; + +import android.annotation.TargetApi; +import android.content.res.Resources; +import android.os.Binder; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import android.view.textservice.SentenceSuggestionsInfo; +import android.view.textservice.SuggestionsInfo; +import android.view.textservice.TextInfo; + +import org.kelar.inputmethod.compat.TextInfoCompatUtils; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.utils.SpannableStringUtils; + +import java.util.ArrayList; +import java.util.Locale; + +public final class AndroidSpellCheckerSession extends AndroidWordLevelSpellCheckerSession { + private static final String TAG = AndroidSpellCheckerSession.class.getSimpleName(); + private static final boolean DBG = false; + private final Resources mResources; + private SentenceLevelAdapter mSentenceLevelAdapter; + + public AndroidSpellCheckerSession(AndroidSpellCheckerService service) { + super(service); + mResources = service.getResources(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private SentenceSuggestionsInfo fixWronglyInvalidatedWordWithSingleQuote(TextInfo ti, + SentenceSuggestionsInfo ssi) { + final CharSequence typedText = TextInfoCompatUtils.getCharSequenceOrString(ti); + if (!typedText.toString().contains(AndroidSpellCheckerService.SINGLE_QUOTE)) { + return null; + } + final int N = ssi.getSuggestionsCount(); + final ArrayList<Integer> additionalOffsets = new ArrayList<>(); + final ArrayList<Integer> additionalLengths = new ArrayList<>(); + final ArrayList<SuggestionsInfo> additionalSuggestionsInfos = new ArrayList<>(); + CharSequence currentWord = null; + for (int i = 0; i < N; ++i) { + final SuggestionsInfo si = ssi.getSuggestionsInfoAt(i); + final int flags = si.getSuggestionsAttributes(); + if ((flags & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) == 0) { + continue; + } + final int offset = ssi.getOffsetAt(i); + final int length = ssi.getLengthAt(i); + final CharSequence subText = typedText.subSequence(offset, offset + length); + final NgramContext ngramContext = + new NgramContext(new NgramContext.WordInfo(currentWord)); + currentWord = subText; + if (!subText.toString().contains(AndroidSpellCheckerService.SINGLE_QUOTE)) { + continue; + } + // Split preserving spans. + final CharSequence[] splitTexts = SpannableStringUtils.split(subText, + AndroidSpellCheckerService.SINGLE_QUOTE, + true /* preserveTrailingEmptySegments */); + if (splitTexts == null || splitTexts.length <= 1) { + continue; + } + final int splitNum = splitTexts.length; + for (int j = 0; j < splitNum; ++j) { + final CharSequence splitText = splitTexts[j]; + if (TextUtils.isEmpty(splitText)) { + continue; + } + if (mSuggestionsCache.getSuggestionsFromCache(splitText.toString()) == null) { + continue; + } + final int newLength = splitText.length(); + // Neither RESULT_ATTR_IN_THE_DICTIONARY nor RESULT_ATTR_LOOKS_LIKE_TYPO + final int newFlags = 0; + final SuggestionsInfo newSi = new SuggestionsInfo(newFlags, EMPTY_STRING_ARRAY); + newSi.setCookieAndSequence(si.getCookie(), si.getSequence()); + if (DBG) { + Log.d(TAG, "Override and remove old span over: " + splitText + ", " + + offset + "," + newLength); + } + additionalOffsets.add(offset); + additionalLengths.add(newLength); + additionalSuggestionsInfos.add(newSi); + } + } + final int additionalSize = additionalOffsets.size(); + if (additionalSize <= 0) { + return null; + } + final int suggestionsSize = N + additionalSize; + final int[] newOffsets = new int[suggestionsSize]; + final int[] newLengths = new int[suggestionsSize]; + final SuggestionsInfo[] newSuggestionsInfos = new SuggestionsInfo[suggestionsSize]; + int i; + for (i = 0; i < N; ++i) { + newOffsets[i] = ssi.getOffsetAt(i); + newLengths[i] = ssi.getLengthAt(i); + newSuggestionsInfos[i] = ssi.getSuggestionsInfoAt(i); + } + for (; i < suggestionsSize; ++i) { + newOffsets[i] = additionalOffsets.get(i - N); + newLengths[i] = additionalLengths.get(i - N); + newSuggestionsInfos[i] = additionalSuggestionsInfos.get(i - N); + } + return new SentenceSuggestionsInfo(newSuggestionsInfos, newOffsets, newLengths); + } + + @Override + public SentenceSuggestionsInfo[] onGetSentenceSuggestionsMultiple(TextInfo[] textInfos, + int suggestionsLimit) { + final SentenceSuggestionsInfo[] retval = splitAndSuggest(textInfos, suggestionsLimit); + if (retval == null || retval.length != textInfos.length) { + return retval; + } + for (int i = 0; i < retval.length; ++i) { + final SentenceSuggestionsInfo tempSsi = + fixWronglyInvalidatedWordWithSingleQuote(textInfos[i], retval[i]); + if (tempSsi != null) { + retval[i] = tempSsi; + } + } + return retval; + } + + /** + * Get sentence suggestions for specified texts in an array of TextInfo. This is taken from + * SpellCheckerService#onGetSentenceSuggestionsMultiple that we can't use because it's + * using private variables. + * The default implementation splits the input text to words and returns + * {@link SentenceSuggestionsInfo} which contains suggestions for each word. + * This function will run on the incoming IPC thread. + * So, this is not called on the main thread, + * but will be called in series on another thread. + * @param textInfos an array of the text metadata + * @param suggestionsLimit the maximum number of suggestions to be returned + * @return an array of {@link SentenceSuggestionsInfo} returned by + * {@link android.service.textservice.SpellCheckerService.Session#onGetSuggestions(TextInfo, int)} + */ + private SentenceSuggestionsInfo[] splitAndSuggest(TextInfo[] textInfos, int suggestionsLimit) { + if (textInfos == null || textInfos.length == 0) { + return SentenceLevelAdapter.getEmptySentenceSuggestionsInfo(); + } + SentenceLevelAdapter sentenceLevelAdapter; + synchronized(this) { + sentenceLevelAdapter = mSentenceLevelAdapter; + if (sentenceLevelAdapter == null) { + final String localeStr = getLocale(); + if (!TextUtils.isEmpty(localeStr)) { + sentenceLevelAdapter = new SentenceLevelAdapter(mResources, + new Locale(localeStr)); + mSentenceLevelAdapter = sentenceLevelAdapter; + } + } + } + if (sentenceLevelAdapter == null) { + return SentenceLevelAdapter.getEmptySentenceSuggestionsInfo(); + } + final int infosSize = textInfos.length; + final SentenceSuggestionsInfo[] retval = new SentenceSuggestionsInfo[infosSize]; + for (int i = 0; i < infosSize; ++i) { + final SentenceLevelAdapter.SentenceTextInfoParams textInfoParams = + sentenceLevelAdapter.getSplitWords(textInfos[i]); + final ArrayList<SentenceLevelAdapter.SentenceWordItem> mItems = + textInfoParams.mItems; + final int itemsSize = mItems.size(); + final TextInfo[] splitTextInfos = new TextInfo[itemsSize]; + for (int j = 0; j < itemsSize; ++j) { + splitTextInfos[j] = mItems.get(j).mTextInfo; + } + retval[i] = SentenceLevelAdapter.reconstructSuggestions( + textInfoParams, onGetSuggestionsMultiple( + splitTextInfos, suggestionsLimit, true)); + } + return retval; + } + + @Override + public SuggestionsInfo[] onGetSuggestionsMultiple(TextInfo[] textInfos, + int suggestionsLimit, boolean sequentialWords) { + long ident = Binder.clearCallingIdentity(); + try { + final int length = textInfos.length; + final SuggestionsInfo[] retval = new SuggestionsInfo[length]; + for (int i = 0; i < length; ++i) { + final CharSequence prevWord; + if (sequentialWords && i > 0) { + final TextInfo prevTextInfo = textInfos[i - 1]; + final CharSequence prevWordCandidate = + TextInfoCompatUtils.getCharSequenceOrString(prevTextInfo); + // Note that an empty string would be used to indicate the initial word + // in the future. + prevWord = TextUtils.isEmpty(prevWordCandidate) ? null : prevWordCandidate; + } else { + prevWord = null; + } + final NgramContext ngramContext = + new NgramContext(new NgramContext.WordInfo(prevWord)); + final TextInfo textInfo = textInfos[i]; + retval[i] = onGetSuggestionsInternal(textInfo, ngramContext, suggestionsLimit); + retval[i].setCookieAndSequence(textInfo.getCookie(), textInfo.getSequence()); + } + return retval; + } finally { + Binder.restoreCallingIdentity(ident); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java new file mode 100644 index 000000000..9463a8fad --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 org.kelar.inputmethod.latin.spellcheck; + +import android.service.textservice.SpellCheckerService.Session; + +public abstract class AndroidSpellCheckerSessionFactory { + public static Session newInstance(AndroidSpellCheckerService service) { + return new AndroidSpellCheckerSession(service); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java new file mode 100644 index 000000000..2f1fc868b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java @@ -0,0 +1,390 @@ +/* + * 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 org.kelar.inputmethod.latin.spellcheck; + +import android.content.ContentResolver; +import android.database.ContentObserver; +import android.os.Binder; +import android.provider.UserDictionary.Words; +import android.service.textservice.SpellCheckerService.Session; +import android.text.TextUtils; +import android.util.Log; +import android.util.LruCache; +import android.view.textservice.SuggestionsInfo; +import android.view.textservice.TextInfo; + +import org.kelar.inputmethod.compat.SuggestionsInfoCompatUtils; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.WordComposer; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.utils.BinaryDictionaryUtils; +import org.kelar.inputmethod.latin.utils.ScriptUtils; +import org.kelar.inputmethod.latin.utils.StatsUtils; +import org.kelar.inputmethod.latin.utils.SuggestionResults; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public abstract class AndroidWordLevelSpellCheckerSession extends Session { + private static final String TAG = AndroidWordLevelSpellCheckerSession.class.getSimpleName(); + + public final static String[] EMPTY_STRING_ARRAY = new String[0]; + + // Immutable, but not available in the constructor. + private Locale mLocale; + // Cache this for performance + private int mScript; // One of SCRIPT_LATIN or SCRIPT_CYRILLIC for now. + private final AndroidSpellCheckerService mService; + protected final SuggestionsCache mSuggestionsCache = new SuggestionsCache(); + private final ContentObserver mObserver; + + private static final String quotesRegexp = + "(\\u0022|\\u0027|\\u0060|\\u00B4|\\u2018|\\u2018|\\u201C|\\u201D)"; + + private static final class SuggestionsParams { + public final String[] mSuggestions; + public final int mFlags; + public SuggestionsParams(String[] suggestions, int flags) { + mSuggestions = suggestions; + mFlags = flags; + } + } + + protected static final class SuggestionsCache { + private static final int MAX_CACHE_SIZE = 50; + private final LruCache<String, SuggestionsParams> mUnigramSuggestionsInfoCache = + new LruCache<>(MAX_CACHE_SIZE); + + private static String generateKey(final String query) { + return query + ""; + } + + public SuggestionsParams getSuggestionsFromCache(final String query) { + return mUnigramSuggestionsInfoCache.get(query); + } + + public void putSuggestionsToCache( + final String query, final String[] suggestions, final int flags) { + if (suggestions == null || TextUtils.isEmpty(query)) { + return; + } + mUnigramSuggestionsInfoCache.put( + generateKey(query), + new SuggestionsParams(suggestions, flags)); + } + + public void clearCache() { + mUnigramSuggestionsInfoCache.evictAll(); + } + } + + AndroidWordLevelSpellCheckerSession(final AndroidSpellCheckerService service) { + mService = service; + final ContentResolver cres = service.getContentResolver(); + + mObserver = new ContentObserver(null) { + @Override + public void onChange(boolean self) { + mSuggestionsCache.clearCache(); + } + }; + cres.registerContentObserver(Words.CONTENT_URI, true, mObserver); + } + + @Override + public void onCreate() { + final String localeString = getLocale(); + mLocale = (null == localeString) ? null + : LocaleUtils.constructLocaleFromString(localeString); + mScript = ScriptUtils.getScriptFromSpellCheckerLocale(mLocale); + } + + @Override + public void onClose() { + final ContentResolver cres = mService.getContentResolver(); + cres.unregisterContentObserver(mObserver); + } + + private static final int CHECKABILITY_CHECKABLE = 0; + private static final int CHECKABILITY_TOO_MANY_NON_LETTERS = 1; + private static final int CHECKABILITY_CONTAINS_PERIOD = 2; + private static final int CHECKABILITY_EMAIL_OR_URL = 3; + private static final int CHECKABILITY_FIRST_LETTER_UNCHECKABLE = 4; + private static final int CHECKABILITY_TOO_SHORT = 5; + /** + * Finds out whether a particular string should be filtered out of spell checking. + * + * This will loosely match URLs, numbers, symbols. To avoid always underlining words that + * we know we will never recognize, this accepts a script identifier that should be one + * of the SCRIPT_* constants defined above, to rule out quickly characters from very + * different languages. + * + * @param text the string to evaluate. + * @param script the identifier for the script this spell checker recognizes + * @return one of the FILTER_OUT_* constants above. + */ + private static int getCheckabilityInScript(final String text, final int script) { + if (TextUtils.isEmpty(text) || text.length() <= 1) return CHECKABILITY_TOO_SHORT; + + // TODO: check if an equivalent processing can't be done more quickly with a + // compiled regexp. + // Filter by first letter + final int firstCodePoint = text.codePointAt(0); + // Filter out words that don't start with a letter or an apostrophe + if (!ScriptUtils.isLetterPartOfScript(firstCodePoint, script) + && '\'' != firstCodePoint) return CHECKABILITY_FIRST_LETTER_UNCHECKABLE; + + // Filter contents + final int length = text.length(); + int letterCount = 0; + for (int i = 0; i < length; i = text.offsetByCodePoints(i, 1)) { + final int codePoint = text.codePointAt(i); + // Any word containing a COMMERCIAL_AT is probably an e-mail address + // Any word containing a SLASH is probably either an ad-hoc combination of two + // words or a URI - in either case we don't want to spell check that + if (Constants.CODE_COMMERCIAL_AT == codePoint || Constants.CODE_SLASH == codePoint) { + return CHECKABILITY_EMAIL_OR_URL; + } + // If the string contains a period, native returns strange suggestions (it seems + // to return suggestions for everything up to the period only and to ignore the + // rest), so we suppress lookup if there is a period. + // TODO: investigate why native returns these suggestions and remove this code. + if (Constants.CODE_PERIOD == codePoint) { + return CHECKABILITY_CONTAINS_PERIOD; + } + if (ScriptUtils.isLetterPartOfScript(codePoint, script)) ++letterCount; + } + // Guestimate heuristic: perform spell checking if at least 3/4 of the characters + // in this word are letters + return (letterCount * 4 < length * 3) + ? CHECKABILITY_TOO_MANY_NON_LETTERS : CHECKABILITY_CHECKABLE; + } + + /** + * Helper method to test valid capitalizations of a word. + * + * If the "text" is lower-case, we test only the exact string. + * If the "Text" is capitalized, we test the exact string "Text" and the lower-cased + * version of it "text". + * If the "TEXT" is fully upper case, we test the exact string "TEXT", the lower-cased + * version of it "text" and the capitalized version of it "Text". + */ + private boolean isInDictForAnyCapitalization(final String text, final int capitalizeType) { + // If the word is in there as is, then it's in the dictionary. If not, we'll test lower + // case versions, but only if the word is not already all-lower case or mixed case. + if (mService.isValidWord(mLocale, text)) return true; + if (StringUtils.CAPITALIZE_NONE == capitalizeType) return false; + + // If we come here, we have a capitalized word (either First- or All-). + // Downcase the word and look it up again. If the word is only capitalized, we + // tested all possibilities, so if it's still negative we can return false. + final String lowerCaseText = text.toLowerCase(mLocale); + if (mService.isValidWord(mLocale, lowerCaseText)) return true; + if (StringUtils.CAPITALIZE_FIRST == capitalizeType) return false; + + // If the lower case version is not in the dictionary, it's still possible + // that we have an all-caps version of a word that needs to be capitalized + // according to the dictionary. E.g. "GERMANS" only exists in the dictionary as "Germans". + return mService.isValidWord(mLocale, + StringUtils.capitalizeFirstAndDowncaseRest(lowerCaseText, mLocale)); + } + + // Note : this must be reentrant + /** + * Gets a list of suggestions for a specific string. This returns a list of possible + * corrections for the text passed as an argument. It may split or group words, and + * even perform grammatical analysis. + */ + private SuggestionsInfo onGetSuggestionsInternal(final TextInfo textInfo, + final int suggestionsLimit) { + return onGetSuggestionsInternal(textInfo, null, suggestionsLimit); + } + + protected SuggestionsInfo onGetSuggestionsInternal( + final TextInfo textInfo, final NgramContext ngramContext, final int suggestionsLimit) { + try { + final String text = textInfo.getText(). + replaceAll(AndroidSpellCheckerService.APOSTROPHE, + AndroidSpellCheckerService.SINGLE_QUOTE). + replaceAll("^" + quotesRegexp, ""). + replaceAll(quotesRegexp + "$", ""); + + if (!mService.hasMainDictionaryForLocale(mLocale)) { + return AndroidSpellCheckerService.getNotInDictEmptySuggestions( + false /* reportAsTypo */); + } + + // Handle special patterns like email, URI, telephone number. + final int checkability = getCheckabilityInScript(text, mScript); + if (CHECKABILITY_CHECKABLE != checkability) { + if (CHECKABILITY_CONTAINS_PERIOD == checkability) { + final String[] splitText = text.split(Constants.REGEXP_PERIOD); + boolean allWordsAreValid = true; + for (final String word : splitText) { + if (!mService.isValidWord(mLocale, word)) { + allWordsAreValid = false; + break; + } + } + if (allWordsAreValid) { + return new SuggestionsInfo(SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO + | SuggestionsInfo.RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS, + new String[] { + TextUtils.join(Constants.STRING_SPACE, splitText) }); + } + } + return mService.isValidWord(mLocale, text) ? + AndroidSpellCheckerService.getInDictEmptySuggestions() : + AndroidSpellCheckerService.getNotInDictEmptySuggestions( + CHECKABILITY_CONTAINS_PERIOD == checkability /* reportAsTypo */); + } + + // Handle normal words. + final int capitalizeType = StringUtils.getCapitalizationType(text); + + if (isInDictForAnyCapitalization(text, capitalizeType)) { + if (DebugFlags.DEBUG_ENABLED) { + Log.i(TAG, "onGetSuggestionsInternal() : [" + text + "] is a valid word"); + } + return AndroidSpellCheckerService.getInDictEmptySuggestions(); + } + if (DebugFlags.DEBUG_ENABLED) { + Log.i(TAG, "onGetSuggestionsInternal() : [" + text + "] is NOT a valid word"); + } + + final Keyboard keyboard = mService.getKeyboardForLocale(mLocale); + if (null == keyboard) { + Log.w(TAG, "onGetSuggestionsInternal() : No keyboard for locale: " + mLocale); + // If there is no keyboard for this locale, don't do any spell-checking. + return AndroidSpellCheckerService.getNotInDictEmptySuggestions( + false /* reportAsTypo */); + } + + final WordComposer composer = new WordComposer(); + final int[] codePoints = StringUtils.toCodePointArray(text); + final int[] coordinates; + coordinates = keyboard.getCoordinates(codePoints); + composer.setComposingWord(codePoints, coordinates); + // TODO: Don't gather suggestions if the limit is <= 0 unless necessary + final SuggestionResults suggestionResults = mService.getSuggestionResults( + mLocale, composer.getComposedDataSnapshot(), ngramContext, keyboard); + final Result result = getResult(capitalizeType, mLocale, suggestionsLimit, + mService.getRecommendedThreshold(), text, suggestionResults); + if (DebugFlags.DEBUG_ENABLED) { + if (result.mSuggestions != null && result.mSuggestions.length > 0) { + final StringBuilder builder = new StringBuilder(); + for (String suggestion : result.mSuggestions) { + builder.append(" ["); + builder.append(suggestion); + builder.append("]"); + } + Log.i(TAG, "onGetSuggestionsInternal() : Suggestions =" + builder); + } + } + // Handle word not in dictionary. + // This is called only once per unique word, so entering multiple + // instances of the same word does not result in more than one call + // to this method. + // Also, upon changing the orientation of the device, this is called + // again for every unique invalid word in the text box. + StatsUtils.onInvalidWordIdentification(text); + + final int flags = + SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO + | (result.mHasRecommendedSuggestions + ? SuggestionsInfoCompatUtils + .getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS() + : 0); + final SuggestionsInfo retval = new SuggestionsInfo(flags, result.mSuggestions); + mSuggestionsCache.putSuggestionsToCache(text, result.mSuggestions, flags); + return retval; + } catch (RuntimeException e) { + // Don't kill the keyboard if there is a bug in the spell checker + Log.e(TAG, "Exception while spellchecking", e); + return AndroidSpellCheckerService.getNotInDictEmptySuggestions( + false /* reportAsTypo */); + } + } + + private static final class Result { + public final String[] mSuggestions; + public final boolean mHasRecommendedSuggestions; + public Result(final String[] gatheredSuggestions, final boolean hasRecommendedSuggestions) { + mSuggestions = gatheredSuggestions; + mHasRecommendedSuggestions = hasRecommendedSuggestions; + } + } + + private static Result getResult(final int capitalizeType, final Locale locale, + final int suggestionsLimit, final float recommendedThreshold, final String originalText, + final SuggestionResults suggestionResults) { + if (suggestionResults.isEmpty() || suggestionsLimit <= 0) { + return new Result(null /* gatheredSuggestions */, + false /* hasRecommendedSuggestions */); + } + final ArrayList<String> suggestions = new ArrayList<>(); + for (final SuggestedWordInfo suggestedWordInfo : suggestionResults) { + final String suggestion; + if (StringUtils.CAPITALIZE_ALL == capitalizeType) { + suggestion = suggestedWordInfo.mWord.toUpperCase(locale); + } else if (StringUtils.CAPITALIZE_FIRST == capitalizeType) { + suggestion = StringUtils.capitalizeFirstCodePoint( + suggestedWordInfo.mWord, locale); + } else { + suggestion = suggestedWordInfo.mWord; + } + suggestions.add(suggestion); + } + StringUtils.removeDupes(suggestions); + // This returns a String[], while toArray() returns an Object[] which cannot be cast + // into a String[]. + final List<String> gatheredSuggestionsList = + suggestions.subList(0, Math.min(suggestions.size(), suggestionsLimit)); + final String[] gatheredSuggestions = + gatheredSuggestionsList.toArray(new String[gatheredSuggestionsList.size()]); + + final int bestScore = suggestionResults.first().mScore; + final String bestSuggestion = suggestions.get(0); + final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore( + originalText, bestSuggestion, bestScore); + final boolean hasRecommendedSuggestions = (normalizedScore > recommendedThreshold); + return new Result(gatheredSuggestions, hasRecommendedSuggestions); + } + + /* + * The spell checker acts on its own behalf. That is needed, in particular, to be able to + * access the dictionary files, which the provider restricts to the identity of Latin IME. + * Since it's called externally by the application, the spell checker is using the identity + * of the application by default unless we clearCallingIdentity. + * That's what the following method does. + */ + @Override + public SuggestionsInfo onGetSuggestions(final TextInfo textInfo, final int suggestionsLimit) { + long ident = Binder.clearCallingIdentity(); + try { + return onGetSuggestionsInternal(textInfo, suggestionsLimit); + } finally { + Binder.restoreCallingIdentity(ident); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java new file mode 100644 index 000000000..4dbcd092e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.spellcheck; + +import android.annotation.TargetApi; +import android.content.res.Resources; +import android.os.Build; +import android.view.textservice.SentenceSuggestionsInfo; +import android.view.textservice.SuggestionsInfo; +import android.view.textservice.TextInfo; + +import org.kelar.inputmethod.compat.TextInfoCompatUtils; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; +import org.kelar.inputmethod.latin.utils.RunInLocale; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * This code is mostly lifted directly from android.service.textservice.SpellCheckerService in + * the framework; maybe that should be protected instead, so that implementers don't have to + * rewrite everything for any small change. + */ +public class SentenceLevelAdapter { + private static class EmptySentenceSuggestionsInfosInitializationHolder { + public static final SentenceSuggestionsInfo[] EMPTY_SENTENCE_SUGGESTIONS_INFOS = + new SentenceSuggestionsInfo[]{}; + } + private static final SuggestionsInfo EMPTY_SUGGESTIONS_INFO = new SuggestionsInfo(0, null); + + public static SentenceSuggestionsInfo[] getEmptySentenceSuggestionsInfo() { + return EmptySentenceSuggestionsInfosInitializationHolder.EMPTY_SENTENCE_SUGGESTIONS_INFOS; + } + + /** + * Container for split TextInfo parameters + */ + public static class SentenceWordItem { + public final TextInfo mTextInfo; + public final int mStart; + public final int mLength; + public SentenceWordItem(TextInfo ti, int start, int end) { + mTextInfo = ti; + mStart = start; + mLength = end - start; + } + } + + /** + * Container for originally queried TextInfo and parameters + */ + public static class SentenceTextInfoParams { + final TextInfo mOriginalTextInfo; + final ArrayList<SentenceWordItem> mItems; + final int mSize; + public SentenceTextInfoParams(TextInfo ti, ArrayList<SentenceWordItem> items) { + mOriginalTextInfo = ti; + mItems = items; + mSize = items.size(); + } + } + + private static class WordIterator { + private final SpacingAndPunctuations mSpacingAndPunctuations; + public WordIterator(final Resources res, final Locale locale) { + final RunInLocale<SpacingAndPunctuations> job = + new RunInLocale<SpacingAndPunctuations>() { + @Override + protected SpacingAndPunctuations job(final Resources r) { + return new SpacingAndPunctuations(r); + } + }; + mSpacingAndPunctuations = job.runInLocale(res, locale); + } + + public int getEndOfWord(final CharSequence sequence, final int fromIndex) { + final int length = sequence.length(); + int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1); + while (index < length) { + final int codePoint = Character.codePointAt(sequence, index); + if (mSpacingAndPunctuations.isWordSeparator(codePoint)) { + // If it's a period, we want to stop here only if it's followed by another + // word separator. In all other cases we stop here. + if (Constants.CODE_PERIOD == codePoint) { + final int indexOfNextCodePoint = + index + Character.charCount(Constants.CODE_PERIOD); + if (indexOfNextCodePoint < length + && mSpacingAndPunctuations.isWordSeparator( + Character.codePointAt(sequence, indexOfNextCodePoint))) { + return index; + } + } else { + return index; + } + } + index += Character.charCount(codePoint); + } + return index; + } + + public int getBeginningOfNextWord(final CharSequence sequence, final int fromIndex) { + final int length = sequence.length(); + if (fromIndex >= length) { + return -1; + } + int index = fromIndex < 0 ? 0 : Character.offsetByCodePoints(sequence, fromIndex, 1); + while (index < length) { + final int codePoint = Character.codePointAt(sequence, index); + if (!mSpacingAndPunctuations.isWordSeparator(codePoint)) { + return index; + } + index += Character.charCount(codePoint); + } + return -1; + } + } + + private final WordIterator mWordIterator; + public SentenceLevelAdapter(final Resources res, final Locale locale) { + mWordIterator = new WordIterator(res, locale); + } + + public SentenceTextInfoParams getSplitWords(TextInfo originalTextInfo) { + final WordIterator wordIterator = mWordIterator; + final CharSequence originalText = + TextInfoCompatUtils.getCharSequenceOrString(originalTextInfo); + final int cookie = originalTextInfo.getCookie(); + final int start = -1; + final int end = originalText.length(); + final ArrayList<SentenceWordItem> wordItems = new ArrayList<>(); + int wordStart = wordIterator.getBeginningOfNextWord(originalText, start); + int wordEnd = wordIterator.getEndOfWord(originalText, wordStart); + while (wordStart <= end && wordEnd != -1 && wordStart != -1) { + if (wordEnd >= start && wordEnd > wordStart) { + final TextInfo ti = TextInfoCompatUtils.newInstance(originalText, wordStart, + wordEnd, cookie, originalText.subSequence(wordStart, wordEnd).hashCode()); + wordItems.add(new SentenceWordItem(ti, wordStart, wordEnd)); + } + wordStart = wordIterator.getBeginningOfNextWord(originalText, wordEnd); + if (wordStart == -1) { + break; + } + wordEnd = wordIterator.getEndOfWord(originalText, wordStart); + } + return new SentenceTextInfoParams(originalTextInfo, wordItems); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + public static SentenceSuggestionsInfo reconstructSuggestions( + SentenceTextInfoParams originalTextInfoParams, SuggestionsInfo[] results) { + if (results == null || results.length == 0) { + return null; + } + if (originalTextInfoParams == null) { + return null; + } + final int originalCookie = originalTextInfoParams.mOriginalTextInfo.getCookie(); + final int originalSequence = + originalTextInfoParams.mOriginalTextInfo.getSequence(); + + final int querySize = originalTextInfoParams.mSize; + final int[] offsets = new int[querySize]; + final int[] lengths = new int[querySize]; + final SuggestionsInfo[] reconstructedSuggestions = new SuggestionsInfo[querySize]; + for (int i = 0; i < querySize; ++i) { + final SentenceWordItem item = originalTextInfoParams.mItems.get(i); + SuggestionsInfo result = null; + for (int j = 0; j < results.length; ++j) { + final SuggestionsInfo cur = results[j]; + if (cur != null && cur.getSequence() == item.mTextInfo.getSequence()) { + result = cur; + result.setCookieAndSequence(originalCookie, originalSequence); + break; + } + } + offsets[i] = item.mStart; + lengths[i] = item.mLength; + reconstructedSuggestions[i] = result != null ? result : EMPTY_SUGGESTIONS_INFO; + } + return new SentenceSuggestionsInfo(reconstructedSuggestions, offsets, lengths); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java new file mode 100644 index 000000000..acbfa8666 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.spellcheck; + +import org.kelar.inputmethod.latin.permissions.PermissionsManager; +import org.kelar.inputmethod.latin.utils.FragmentUtils; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import androidx.core.app.ActivityCompat; + +/** + * Spell checker preference screen. + */ +public final class SpellCheckerSettingsActivity extends PreferenceActivity + implements ActivityCompat.OnRequestPermissionsResultCallback { + private static final String DEFAULT_FRAGMENT = SpellCheckerSettingsFragment.class.getName(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public Intent getIntent() { + final Intent modIntent = new Intent(super.getIntent()); + modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT); + modIntent.putExtra(EXTRA_NO_HEADERS, true); + return modIntent; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + @Override + public boolean isValidFragment(String fragmentName) { + return FragmentUtils.isValidFragment(fragmentName); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + PermissionsManager.get(this).onRequestPermissionsResult( + requestCode, permissions, grantResults); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java new file mode 100644 index 000000000..e60173932 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.spellcheck; + +import android.Manifest; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceScreen; +import android.preference.SwitchPreference; +import android.text.TextUtils; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.permissions.PermissionsManager; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.settings.SubScreenFragment; +import org.kelar.inputmethod.latin.settings.TwoStatePreferenceHelper; +import org.kelar.inputmethod.latin.utils.ApplicationUtils; + +import static org.kelar.inputmethod.latin.permissions.PermissionsManager.get; + +/** + * Preference screen. + */ +public final class SpellCheckerSettingsFragment extends SubScreenFragment + implements SharedPreferences.OnSharedPreferenceChangeListener, + PermissionsManager.PermissionsResultCallback { + + private SwitchPreference mLookupContactsPreference; + + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + addPreferencesFromResource(R.xml.spell_checker_settings); + final PreferenceScreen preferenceScreen = getPreferenceScreen(); + preferenceScreen.setTitle(ApplicationUtils.getActivityTitleResId( + getActivity(), SpellCheckerSettingsActivity.class)); + TwoStatePreferenceHelper.replaceCheckBoxPreferencesBySwitchPreferences(preferenceScreen); + + mLookupContactsPreference = (SwitchPreference) findPreference( + AndroidSpellCheckerService.PREF_USE_CONTACTS_KEY); + turnOffLookupContactsIfNoPermission(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (!TextUtils.equals(key, AndroidSpellCheckerService.PREF_USE_CONTACTS_KEY)) { + return; + } + + if (!sharedPreferences.getBoolean(key, false)) { + // don't care if the preference is turned off. + return; + } + + // Check for permissions. + if (PermissionsUtil.checkAllPermissionsGranted( + getActivity() /* context */, Manifest.permission.READ_CONTACTS)) { + return; // all permissions granted, no need to request permissions. + } + + get(getActivity() /* context */).requestPermissions(this /* PermissionsResultCallback */, + getActivity() /* activity */, Manifest.permission.READ_CONTACTS); + } + + @Override + public void onRequestPermissionsResult(boolean allGranted) { + turnOffLookupContactsIfNoPermission(); + } + + private void turnOffLookupContactsIfNoPermission() { + if (!PermissionsUtil.checkAllPermissionsGranted( + getActivity(), Manifest.permission.READ_CONTACTS)) { + mLookupContactsPreference.setChecked(false); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestions.java b/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestions.java new file mode 100644 index 000000000..5ea6ccd99 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestions.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.suggestions; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.internal.KeyboardBuilder; +import org.kelar.inputmethod.keyboard.internal.KeyboardIconsSet; +import org.kelar.inputmethod.keyboard.internal.KeyboardParams; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.utils.TypefaceUtils; + +public final class MoreSuggestions extends Keyboard { + public final SuggestedWords mSuggestedWords; + + MoreSuggestions(final MoreSuggestionsParam params, final SuggestedWords suggestedWords) { + super(params); + mSuggestedWords = suggestedWords; + } + + private static final class MoreSuggestionsParam extends KeyboardParams { + private final int[] mWidths = new int[SuggestedWords.MAX_SUGGESTIONS]; + private final int[] mRowNumbers = new int[SuggestedWords.MAX_SUGGESTIONS]; + private final int[] mColumnOrders = new int[SuggestedWords.MAX_SUGGESTIONS]; + private final int[] mNumColumnsInRow = new int[SuggestedWords.MAX_SUGGESTIONS]; + private static final int MAX_COLUMNS_IN_ROW = 3; + private int mNumRows; + public Drawable mDivider; + public int mDividerWidth; + + public MoreSuggestionsParam() { + super(); + } + + public int layout(final SuggestedWords suggestedWords, final int fromIndex, + final int maxWidth, final int minWidth, final int maxRow, final Paint paint, + final Resources res) { + clearKeys(); + mDivider = res.getDrawable(R.drawable.more_suggestions_divider); + mDividerWidth = mDivider.getIntrinsicWidth(); + final float padding = res.getDimension( + R.dimen.config_more_suggestions_key_horizontal_padding); + + int row = 0; + int index = fromIndex; + int rowStartIndex = fromIndex; + final int size = Math.min(suggestedWords.size(), SuggestedWords.MAX_SUGGESTIONS); + while (index < size) { + final String word; + if (isIndexSubjectToAutoCorrection(suggestedWords, index)) { + // INDEX_OF_AUTO_CORRECTION and INDEX_OF_TYPED_WORD got swapped. + word = suggestedWords.getLabel(SuggestedWords.INDEX_OF_TYPED_WORD); + } else { + word = suggestedWords.getLabel(index); + } + // TODO: Should take care of text x-scaling. + mWidths[index] = (int)(TypefaceUtils.getStringWidth(word, paint) + padding); + final int numColumn = index - rowStartIndex + 1; + final int columnWidth = + (maxWidth - mDividerWidth * (numColumn - 1)) / numColumn; + if (numColumn > MAX_COLUMNS_IN_ROW + || !fitInWidth(rowStartIndex, index + 1, columnWidth)) { + if ((row + 1) >= maxRow) { + break; + } + mNumColumnsInRow[row] = index - rowStartIndex; + rowStartIndex = index; + row++; + } + mColumnOrders[index] = index - rowStartIndex; + mRowNumbers[index] = row; + index++; + } + mNumColumnsInRow[row] = index - rowStartIndex; + mNumRows = row + 1; + mBaseWidth = mOccupiedWidth = Math.max( + minWidth, calcurateMaxRowWidth(fromIndex, index)); + mBaseHeight = mOccupiedHeight = mNumRows * mDefaultRowHeight + mVerticalGap; + return index - fromIndex; + } + + private boolean fitInWidth(final int startIndex, final int endIndex, final int width) { + for (int index = startIndex; index < endIndex; index++) { + if (mWidths[index] > width) + return false; + } + return true; + } + + private int calcurateMaxRowWidth(final int startIndex, final int endIndex) { + int maxRowWidth = 0; + int index = startIndex; + for (int row = 0; row < mNumRows; row++) { + final int numColumnInRow = mNumColumnsInRow[row]; + int maxKeyWidth = 0; + while (index < endIndex && mRowNumbers[index] == row) { + maxKeyWidth = Math.max(maxKeyWidth, mWidths[index]); + index++; + } + maxRowWidth = Math.max(maxRowWidth, + maxKeyWidth * numColumnInRow + mDividerWidth * (numColumnInRow - 1)); + } + return maxRowWidth; + } + + private static final int[][] COLUMN_ORDER_TO_NUMBER = { + { 0 }, // center + { 1, 0 }, // right-left + { 1, 0, 2 }, // center-left-right + }; + + public int getNumColumnInRow(final int index) { + return mNumColumnsInRow[mRowNumbers[index]]; + } + + public int getColumnNumber(final int index) { + final int columnOrder = mColumnOrders[index]; + final int numColumn = getNumColumnInRow(index); + return COLUMN_ORDER_TO_NUMBER[numColumn - 1][columnOrder]; + } + + public int getX(final int index) { + final int columnNumber = getColumnNumber(index); + return columnNumber * (getWidth(index) + mDividerWidth); + } + + public int getY(final int index) { + final int row = mRowNumbers[index]; + return (mNumRows -1 - row) * mDefaultRowHeight + mTopPadding; + } + + public int getWidth(final int index) { + final int numColumnInRow = getNumColumnInRow(index); + return (mOccupiedWidth - mDividerWidth * (numColumnInRow - 1)) / numColumnInRow; + } + + public void markAsEdgeKey(final Key key, final int index) { + final int row = mRowNumbers[index]; + if (row == 0) + key.markAsBottomEdge(this); + if (row == mNumRows - 1) + key.markAsTopEdge(this); + + final int numColumnInRow = mNumColumnsInRow[row]; + final int column = getColumnNumber(index); + if (column == 0) + key.markAsLeftEdge(this); + if (column == numColumnInRow - 1) + key.markAsRightEdge(this); + } + } + + static boolean isIndexSubjectToAutoCorrection(final SuggestedWords suggestedWords, + final int index) { + return suggestedWords.mWillAutoCorrect && index == SuggestedWords.INDEX_OF_AUTO_CORRECTION; + } + + public static final class Builder extends KeyboardBuilder<MoreSuggestionsParam> { + private final MoreSuggestionsView mPaneView; + private SuggestedWords mSuggestedWords; + private int mFromIndex; + private int mToIndex; + + public Builder(final Context context, final MoreSuggestionsView paneView) { + super(context, new MoreSuggestionsParam()); + mPaneView = paneView; + } + + public Builder layout(final SuggestedWords suggestedWords, final int fromIndex, + final int maxWidth, final int minWidth, final int maxRow, + final Keyboard parentKeyboard) { + final int xmlId = R.xml.kbd_suggestions_pane_template; + load(xmlId, parentKeyboard.mId); + mParams.mVerticalGap = mParams.mTopPadding = parentKeyboard.mVerticalGap / 2; + mPaneView.updateKeyboardGeometry(mParams.mDefaultRowHeight); + final int count = mParams.layout(suggestedWords, fromIndex, maxWidth, minWidth, maxRow, + mPaneView.newLabelPaint(null /* key */), mResources); + mFromIndex = fromIndex; + mToIndex = fromIndex + count; + mSuggestedWords = suggestedWords; + return this; + } + + @Override + public MoreSuggestions build() { + final MoreSuggestionsParam params = mParams; + for (int index = mFromIndex; index < mToIndex; index++) { + final int x = params.getX(index); + final int y = params.getY(index); + final int width = params.getWidth(index); + final String word; + final String info; + if (isIndexSubjectToAutoCorrection(mSuggestedWords, index)) { + // INDEX_OF_AUTO_CORRECTION and INDEX_OF_TYPED_WORD got swapped. + word = mSuggestedWords.getLabel(SuggestedWords.INDEX_OF_TYPED_WORD); + info = mSuggestedWords.getDebugString(SuggestedWords.INDEX_OF_TYPED_WORD); + } else { + word = mSuggestedWords.getLabel(index); + info = mSuggestedWords.getDebugString(index); + } + final Key key = new MoreSuggestionKey(word, info, index, params); + params.markAsEdgeKey(key, index); + params.onAddKey(key); + final int columnNumber = params.getColumnNumber(index); + final int numColumnInRow = params.getNumColumnInRow(index); + if (columnNumber < numColumnInRow - 1) { + final Divider divider = new Divider(params, params.mDivider, x + width, y, + params.mDividerWidth, params.mDefaultRowHeight); + params.onAddKey(divider); + } + } + return new MoreSuggestions(params, mSuggestedWords); + } + } + + static final class MoreSuggestionKey extends Key { + public final int mSuggestedWordIndex; + + public MoreSuggestionKey(final String word, final String info, final int index, + final MoreSuggestionsParam params) { + super(word /* label */, KeyboardIconsSet.ICON_UNDEFINED, Constants.CODE_OUTPUT_TEXT, + word /* outputText */, info, 0 /* labelFlags */, Key.BACKGROUND_TYPE_NORMAL, + params.getX(index), params.getY(index), params.getWidth(index), + params.mDefaultRowHeight, params.mHorizontalGap, params.mVerticalGap); + mSuggestedWordIndex = index; + } + } + + private static final class Divider extends Key.Spacer { + private final Drawable mIcon; + + public Divider(final KeyboardParams params, final Drawable icon, final int x, + final int y, final int width, final int height) { + super(params, x, y, width, height); + mIcon = icon; + } + + @Override + public Drawable getIcon(final KeyboardIconsSet iconSet, final int alpha) { + // KeyboardIconsSet and alpha are unused. Use the icon that has been passed to the + // constructor. + // TODO: Drawable itself should have an alpha value. + mIcon.setAlpha(128); + return mIcon; + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestionsView.java b/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestionsView.java new file mode 100644 index 000000000..a899c9a1d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestionsView.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.suggestions; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; + +import org.kelar.inputmethod.keyboard.Key; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.KeyboardActionListener; +import org.kelar.inputmethod.keyboard.MoreKeysKeyboardView; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.suggestions.MoreSuggestions.MoreSuggestionKey; + +/** + * A view that renders a virtual {@link MoreSuggestions}. It handles rendering of keys and detecting + * key presses and touch movements. + */ +public final class MoreSuggestionsView extends MoreKeysKeyboardView { + private static final String TAG = MoreSuggestionsView.class.getSimpleName(); + + public static abstract class MoreSuggestionsListener extends KeyboardActionListener.Adapter { + public abstract void onSuggestionSelected(final SuggestedWordInfo info); + } + + private boolean mIsInModalMode; + + public MoreSuggestionsView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.moreKeysKeyboardViewStyle); + } + + public MoreSuggestionsView(final Context context, final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + } + + // TODO: Remove redundant override method. + @Override + public void setKeyboard(final Keyboard keyboard) { + super.setKeyboard(keyboard); + mIsInModalMode = false; + // With accessibility mode off, {@link #mAccessibilityDelegate} is set to null at the + // above {@link MoreKeysKeyboardView#setKeyboard(Keyboard)} call. + // With accessibility mode on, {@link #mAccessibilityDelegate} is set to a + // {@link MoreKeysKeyboardAccessibilityDelegate} object at the above + // {@link MoreKeysKeyboardView#setKeyboard(Keyboard)} call. + if (mAccessibilityDelegate != null) { + mAccessibilityDelegate.setOpenAnnounce(R.string.spoken_open_more_suggestions); + mAccessibilityDelegate.setCloseAnnounce(R.string.spoken_close_more_suggestions); + } + } + + @Override + protected int getDefaultCoordX() { + final MoreSuggestions pane = (MoreSuggestions)getKeyboard(); + return pane.mOccupiedWidth / 2; + } + + public void updateKeyboardGeometry(final int keyHeight) { + updateKeyDrawParams(keyHeight); + } + + public void setModalMode() { + mIsInModalMode = true; + // Set vertical correction to zero (Reset more keys keyboard sliding allowance + // {@link R#dimen.config_more_keys_keyboard_slide_allowance}). + mKeyDetector.setKeyboard(getKeyboard(), -getPaddingLeft(), -getPaddingTop()); + } + + public boolean isInModalMode() { + return mIsInModalMode; + } + + @Override + protected void onKeyInput(final Key key, final int x, final int y) { + if (!(key instanceof MoreSuggestionKey)) { + Log.e(TAG, "Expected key is MoreSuggestionKey, but found " + + key.getClass().getName()); + return; + } + final Keyboard keyboard = getKeyboard(); + if (!(keyboard instanceof MoreSuggestions)) { + Log.e(TAG, "Expected keyboard is MoreSuggestions, but found " + + keyboard.getClass().getName()); + return; + } + final SuggestedWords suggestedWords = ((MoreSuggestions)keyboard).mSuggestedWords; + final int index = ((MoreSuggestionKey)key).mSuggestedWordIndex; + if (index < 0 || index >= suggestedWords.size()) { + Log.e(TAG, "Selected suggestion has an illegal index: " + index); + return; + } + if (!(mListener instanceof MoreSuggestionsListener)) { + Log.e(TAG, "Expected mListener is MoreSuggestionsListener, but found " + + mListener.getClass().getName()); + return; + } + ((MoreSuggestionsListener)mListener).onSuggestionSelected(suggestedWords.getInfo(index)); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripLayoutHelper.java b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripLayoutHelper.java new file mode 100644 index 000000000..6e95de414 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripLayoutHelper.java @@ -0,0 +1,650 @@ +/* + * 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 org.kelar.inputmethod.latin.suggestions; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.PunctuationSuggestions; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.settings.SettingsValues; +import org.kelar.inputmethod.latin.utils.ResourceUtils; +import org.kelar.inputmethod.latin.utils.ViewLayoutUtils; + +import java.util.ArrayList; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +final class SuggestionStripLayoutHelper { + private static final int DEFAULT_SUGGESTIONS_COUNT_IN_STRIP = 3; + private static final float DEFAULT_CENTER_SUGGESTION_PERCENTILE = 0.40f; + private static final int DEFAULT_MAX_MORE_SUGGESTIONS_ROW = 2; + private static final int PUNCTUATIONS_IN_STRIP = 5; + private static final float MIN_TEXT_XSCALE = 0.70f; + + public final int mPadding; + public final int mDividerWidth; + public final int mSuggestionsStripHeight; + private final int mSuggestionsCountInStrip; + public final int mMoreSuggestionsRowHeight; + private int mMaxMoreSuggestionsRow; + public final float mMinMoreSuggestionsWidth; + public final int mMoreSuggestionsBottomGap; + private boolean mMoreSuggestionsAvailable; + + // The index of these {@link ArrayList} is the position in the suggestion strip. The indices + // increase towards the right for LTR scripts and the left for RTL scripts, starting with 0. + // The position of the most important suggestion is in {@link #mCenterPositionInStrip} + private final ArrayList<TextView> mWordViews; + private final ArrayList<View> mDividerViews; + private final ArrayList<TextView> mDebugInfoViews; + + private final int mColorValidTypedWord; + private final int mColorTypedWord; + private final int mColorAutoCorrect; + private final int mColorSuggested; + private final float mAlphaObsoleted; + private final float mCenterSuggestionWeight; + private final int mCenterPositionInStrip; + private final int mTypedWordPositionWhenAutocorrect; + private final Drawable mMoreSuggestionsHint; + private static final String MORE_SUGGESTIONS_HINT = "\u2026"; + + private static final CharacterStyle BOLD_SPAN = new StyleSpan(Typeface.BOLD); + private static final CharacterStyle UNDERLINE_SPAN = new UnderlineSpan(); + + private final int mSuggestionStripOptions; + // These constants are the flag values of + // {@link R.styleable#SuggestionStripView_suggestionStripOptions} attribute. + private static final int AUTO_CORRECT_BOLD = 0x01; + private static final int AUTO_CORRECT_UNDERLINE = 0x02; + private static final int VALID_TYPED_WORD_BOLD = 0x04; + + public SuggestionStripLayoutHelper(final Context context, final AttributeSet attrs, + final int defStyle, final ArrayList<TextView> wordViews, + final ArrayList<View> dividerViews, final ArrayList<TextView> debugInfoViews) { + mWordViews = wordViews; + mDividerViews = dividerViews; + mDebugInfoViews = debugInfoViews; + + final TextView wordView = wordViews.get(0); + final View dividerView = dividerViews.get(0); + mPadding = wordView.getCompoundPaddingLeft() + wordView.getCompoundPaddingRight(); + dividerView.measure( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + mDividerWidth = dividerView.getMeasuredWidth(); + + final Resources res = wordView.getResources(); + mSuggestionsStripHeight = res.getDimensionPixelSize( + R.dimen.config_suggestions_strip_height); + + final TypedArray a = context.obtainStyledAttributes(attrs, + R.styleable.SuggestionStripView, defStyle, R.style.SuggestionStripView); + mSuggestionStripOptions = a.getInt( + R.styleable.SuggestionStripView_suggestionStripOptions, 0); + mAlphaObsoleted = ResourceUtils.getFraction(a, + R.styleable.SuggestionStripView_alphaObsoleted, 1.0f); + mColorValidTypedWord = a.getColor(R.styleable.SuggestionStripView_colorValidTypedWord, 0); + mColorTypedWord = a.getColor(R.styleable.SuggestionStripView_colorTypedWord, 0); + mColorAutoCorrect = a.getColor(R.styleable.SuggestionStripView_colorAutoCorrect, 0); + mColorSuggested = a.getColor(R.styleable.SuggestionStripView_colorSuggested, 0); + mSuggestionsCountInStrip = a.getInt( + R.styleable.SuggestionStripView_suggestionsCountInStrip, + DEFAULT_SUGGESTIONS_COUNT_IN_STRIP); + mCenterSuggestionWeight = ResourceUtils.getFraction(a, + R.styleable.SuggestionStripView_centerSuggestionPercentile, + DEFAULT_CENTER_SUGGESTION_PERCENTILE); + mMaxMoreSuggestionsRow = a.getInt( + R.styleable.SuggestionStripView_maxMoreSuggestionsRow, + DEFAULT_MAX_MORE_SUGGESTIONS_ROW); + mMinMoreSuggestionsWidth = ResourceUtils.getFraction(a, + R.styleable.SuggestionStripView_minMoreSuggestionsWidth, 1.0f); + a.recycle(); + + mMoreSuggestionsHint = getMoreSuggestionsHint(res, + res.getDimension(R.dimen.config_more_suggestions_hint_text_size), + mColorAutoCorrect); + mCenterPositionInStrip = mSuggestionsCountInStrip / 2; + // Assuming there are at least three suggestions. Also, note that the suggestions are + // laid out according to script direction, so this is left of the center for LTR scripts + // and right of the center for RTL scripts. + mTypedWordPositionWhenAutocorrect = mCenterPositionInStrip - 1; + mMoreSuggestionsBottomGap = res.getDimensionPixelOffset( + R.dimen.config_more_suggestions_bottom_gap); + mMoreSuggestionsRowHeight = res.getDimensionPixelSize( + R.dimen.config_more_suggestions_row_height); + } + + public int getMaxMoreSuggestionsRow() { + return mMaxMoreSuggestionsRow; + } + + private int getMoreSuggestionsHeight() { + return mMaxMoreSuggestionsRow * mMoreSuggestionsRowHeight + mMoreSuggestionsBottomGap; + } + + public void setMoreSuggestionsHeight(final int remainingHeight) { + final int currentHeight = getMoreSuggestionsHeight(); + if (currentHeight <= remainingHeight) { + return; + } + + mMaxMoreSuggestionsRow = (remainingHeight - mMoreSuggestionsBottomGap) + / mMoreSuggestionsRowHeight; + } + + private static Drawable getMoreSuggestionsHint(final Resources res, final float textSize, + final int color) { + final Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setTextAlign(Align.CENTER); + paint.setTextSize(textSize); + paint.setColor(color); + final Rect bounds = new Rect(); + paint.getTextBounds(MORE_SUGGESTIONS_HINT, 0, MORE_SUGGESTIONS_HINT.length(), bounds); + final int width = Math.round(bounds.width() + 0.5f); + final int height = Math.round(bounds.height() + 0.5f); + final Bitmap buffer = Bitmap.createBitmap(width, (height * 3 / 2), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(buffer); + canvas.drawText(MORE_SUGGESTIONS_HINT, width / 2, height, paint); + BitmapDrawable bitmapDrawable = new BitmapDrawable(res, buffer); + bitmapDrawable.setTargetDensity(canvas); + return bitmapDrawable; + } + + private CharSequence getStyledSuggestedWord(final SuggestedWords suggestedWords, + final int indexInSuggestedWords) { + if (indexInSuggestedWords >= suggestedWords.size()) { + return null; + } + final String word = suggestedWords.getLabel(indexInSuggestedWords); + // TODO: don't use the index to decide whether this is the auto-correction/typed word, as + // this is brittle + final boolean isAutoCorrection = suggestedWords.mWillAutoCorrect + && indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION; + final boolean isTypedWordValid = suggestedWords.mTypedWordValid + && indexInSuggestedWords == SuggestedWords.INDEX_OF_TYPED_WORD; + if (!isAutoCorrection && !isTypedWordValid) { + return word; + } + + final Spannable spannedWord = new SpannableString(word); + final int options = mSuggestionStripOptions; + if ((isAutoCorrection && (options & AUTO_CORRECT_BOLD) != 0) + || (isTypedWordValid && (options & VALID_TYPED_WORD_BOLD) != 0)) { + addStyleSpan(spannedWord, BOLD_SPAN); + } + if (isAutoCorrection && (options & AUTO_CORRECT_UNDERLINE) != 0) { + addStyleSpan(spannedWord, UNDERLINE_SPAN); + } + return spannedWord; + } + + /** + * Convert an index of {@link SuggestedWords} to position in the suggestion strip. + * @param indexInSuggestedWords the index of {@link SuggestedWords}. + * @param suggestedWords the suggested words list + * @return Non-negative integer of the position in the suggestion strip. + * Negative integer if the word of the index shouldn't be shown on the suggestion strip. + */ + private int getPositionInSuggestionStrip(final int indexInSuggestedWords, + final SuggestedWords suggestedWords) { + final SettingsValues settingsValues = Settings.getInstance().getCurrent(); + final boolean shouldOmitTypedWord = shouldOmitTypedWord(suggestedWords.mInputStyle, + settingsValues.mGestureFloatingPreviewTextEnabled, + settingsValues.mShouldShowLxxSuggestionUi); + return getPositionInSuggestionStrip(indexInSuggestedWords, suggestedWords.mWillAutoCorrect, + settingsValues.mShouldShowLxxSuggestionUi && shouldOmitTypedWord, + mCenterPositionInStrip, mTypedWordPositionWhenAutocorrect); + } + + @UsedForTesting + static boolean shouldOmitTypedWord(final int inputStyle, + final boolean gestureFloatingPreviewTextEnabled, + final boolean shouldShowUiToAcceptTypedWord) { + final boolean omitTypedWord = (inputStyle == SuggestedWords.INPUT_STYLE_TYPING) + || (inputStyle == SuggestedWords.INPUT_STYLE_TAIL_BATCH) + || (inputStyle == SuggestedWords.INPUT_STYLE_UPDATE_BATCH + && gestureFloatingPreviewTextEnabled); + return shouldShowUiToAcceptTypedWord && omitTypedWord; + } + + @UsedForTesting + static int getPositionInSuggestionStrip(final int indexInSuggestedWords, + final boolean willAutoCorrect, final boolean omitTypedWord, + final int centerPositionInStrip, final int typedWordPositionWhenAutoCorrect) { + if (omitTypedWord) { + if (indexInSuggestedWords == SuggestedWords.INDEX_OF_TYPED_WORD) { + // Ignore. + return -1; + } + if (indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION) { + // Center in the suggestion strip. + return centerPositionInStrip; + } + // If neither of those, the order in the suggestion strip is left of the center first + // then right of the center, to both edges of the suggestion strip. + // For example, center-1, center+1, center-2, center+2, and so on. + final int n = indexInSuggestedWords; + final int offsetFromCenter = (n % 2) == 0 ? -(n / 2) : (n / 2); + final int positionInSuggestionStrip = centerPositionInStrip + offsetFromCenter; + return positionInSuggestionStrip; + } + final int indexToDisplayMostImportantSuggestion; + final int indexToDisplaySecondMostImportantSuggestion; + if (willAutoCorrect) { + indexToDisplayMostImportantSuggestion = SuggestedWords.INDEX_OF_AUTO_CORRECTION; + indexToDisplaySecondMostImportantSuggestion = SuggestedWords.INDEX_OF_TYPED_WORD; + } else { + indexToDisplayMostImportantSuggestion = SuggestedWords.INDEX_OF_TYPED_WORD; + indexToDisplaySecondMostImportantSuggestion = SuggestedWords.INDEX_OF_AUTO_CORRECTION; + } + if (indexInSuggestedWords == indexToDisplayMostImportantSuggestion) { + // Center in the suggestion strip. + return centerPositionInStrip; + } + if (indexInSuggestedWords == indexToDisplaySecondMostImportantSuggestion) { + // Center-1. + return typedWordPositionWhenAutoCorrect; + } + // If neither of those, the order in the suggestion strip is right of the center first + // then left of the center, to both edges of the suggestion strip. + // For example, Center+1, center-2, center+2, center-3, and so on. + final int n = indexInSuggestedWords + 1; + final int offsetFromCenter = (n % 2) == 0 ? -(n / 2) : (n / 2); + final int positionInSuggestionStrip = centerPositionInStrip + offsetFromCenter; + return positionInSuggestionStrip; + } + + private int getSuggestionTextColor(final SuggestedWords suggestedWords, + final int indexInSuggestedWords) { + // Use identity for strings, not #equals : it's the typed word if it's the same object + final boolean isTypedWord = suggestedWords.getInfo(indexInSuggestedWords).isKindOf( + SuggestedWordInfo.KIND_TYPED); + + final int color; + if (indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION + && suggestedWords.mWillAutoCorrect) { + color = mColorAutoCorrect; + } else if (isTypedWord && suggestedWords.mTypedWordValid) { + color = mColorValidTypedWord; + } else if (isTypedWord) { + color = mColorTypedWord; + } else { + color = mColorSuggested; + } + if (suggestedWords.mIsObsoleteSuggestions && !isTypedWord) { + return applyAlpha(color, mAlphaObsoleted); + } + return color; + } + + private static int applyAlpha(final int color, final float alpha) { + final int newAlpha = (int)(Color.alpha(color) * alpha); + return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color)); + } + + private static void addDivider(final ViewGroup stripView, final View dividerView) { + stripView.addView(dividerView); + final LinearLayout.LayoutParams params = + (LinearLayout.LayoutParams)dividerView.getLayoutParams(); + params.gravity = Gravity.CENTER; + } + + /** + * Layout suggestions to the suggestions strip. And returns the start index of more + * suggestions. + * + * @param suggestedWords suggestions to be shown in the suggestions strip. + * @param stripView the suggestions strip view. + * @param placerView the view where the debug info will be placed. + * @return the start index of more suggestions. + */ + public int layoutAndReturnStartIndexOfMoreSuggestions( + final Context context, + final SuggestedWords suggestedWords, + final ViewGroup stripView, + final ViewGroup placerView) { + if (suggestedWords.isPunctuationSuggestions()) { + return layoutPunctuationsAndReturnStartIndexOfMoreSuggestions( + (PunctuationSuggestions)suggestedWords, stripView); + } + + final int wordCountToShow = suggestedWords.getWordCountToShow( + Settings.getInstance().getCurrent().mShouldShowLxxSuggestionUi); + final int startIndexOfMoreSuggestions = setupWordViewsAndReturnStartIndexOfMoreSuggestions( + suggestedWords, mSuggestionsCountInStrip); + final TextView centerWordView = mWordViews.get(mCenterPositionInStrip); + final int stripWidth = stripView.getWidth(); + final int centerWidth = getSuggestionWidth(mCenterPositionInStrip, stripWidth); + if (wordCountToShow == 1 || getTextScaleX(centerWordView.getText(), centerWidth, + centerWordView.getPaint()) < MIN_TEXT_XSCALE) { + // Layout only the most relevant suggested word at the center of the suggestion strip + // by consolidating all slots in the strip. + final int countInStrip = 1; + mMoreSuggestionsAvailable = (wordCountToShow > countInStrip); + layoutWord(context, mCenterPositionInStrip, stripWidth - mPadding); + stripView.addView(centerWordView); + setLayoutWeight(centerWordView, 1.0f, ViewGroup.LayoutParams.MATCH_PARENT); + if (SuggestionStripView.DBG) { + layoutDebugInfo(mCenterPositionInStrip, placerView, stripWidth); + } + final Integer lastIndex = (Integer)centerWordView.getTag(); + return (lastIndex == null ? 0 : lastIndex) + 1; + } + + final int countInStrip = mSuggestionsCountInStrip; + mMoreSuggestionsAvailable = (wordCountToShow > countInStrip); + @SuppressWarnings("unused") + int x = 0; + for (int positionInStrip = 0; positionInStrip < countInStrip; positionInStrip++) { + if (positionInStrip != 0) { + final View divider = mDividerViews.get(positionInStrip); + // Add divider if this isn't the left most suggestion in suggestions strip. + addDivider(stripView, divider); + x += divider.getMeasuredWidth(); + } + + final int width = getSuggestionWidth(positionInStrip, stripWidth); + final TextView wordView = layoutWord(context, positionInStrip, width); + stripView.addView(wordView); + setLayoutWeight(wordView, getSuggestionWeight(positionInStrip), + ViewGroup.LayoutParams.MATCH_PARENT); + x += wordView.getMeasuredWidth(); + + if (SuggestionStripView.DBG) { + layoutDebugInfo(positionInStrip, placerView, x); + } + } + return startIndexOfMoreSuggestions; + } + + /** + * Format appropriately the suggested word in {@link #mWordViews} specified by + * <code>positionInStrip</code>. When the suggested word doesn't exist, the corresponding + * {@link TextView} will be disabled and never respond to user interaction. The suggested word + * may be shrunk or ellipsized to fit in the specified width. + * + * The <code>positionInStrip</code> argument is the index in the suggestion strip. The indices + * increase towards the right for LTR scripts and the left for RTL scripts, starting with 0. + * The position of the most important suggestion is in {@link #mCenterPositionInStrip}. This + * usually doesn't match the index in <code>suggedtedWords</code> -- see + * {@link #getPositionInSuggestionStrip(int,SuggestedWords)}. + * + * @param positionInStrip the position in the suggestion strip. + * @param width the maximum width for layout in pixels. + * @return the {@link TextView} containing the suggested word appropriately formatted. + */ + private TextView layoutWord(final Context context, final int positionInStrip, final int width) { + final TextView wordView = mWordViews.get(positionInStrip); + final CharSequence word = wordView.getText(); + if (positionInStrip == mCenterPositionInStrip && mMoreSuggestionsAvailable) { + // TODO: This "more suggestions hint" should have a nicely designed icon. + wordView.setCompoundDrawablesWithIntrinsicBounds( + null, null, null, mMoreSuggestionsHint); + // HACK: Align with other TextViews that have no compound drawables. + wordView.setCompoundDrawablePadding(-mMoreSuggestionsHint.getIntrinsicHeight()); + } else { + wordView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + } + // {@link StyleSpan} in a content description may cause an issue of TTS/TalkBack. + // Use a simple {@link String} to avoid the issue. + wordView.setContentDescription( + TextUtils.isEmpty(word) + ? context.getResources().getString(R.string.spoken_empty_suggestion) + : word.toString()); + final CharSequence text = getEllipsizedTextWithSettingScaleX( + word, width, wordView.getPaint()); + final float scaleX = wordView.getTextScaleX(); + wordView.setText(text); // TextView.setText() resets text scale x to 1.0. + wordView.setTextScaleX(scaleX); + // A <code>wordView</code> should be disabled when <code>word</code> is empty in order to + // make it unclickable. + // With accessibility touch exploration on, <code>wordView</code> should be enabled even + // when it is empty to avoid announcing as "disabled". + wordView.setEnabled(!TextUtils.isEmpty(word) + || AccessibilityUtils.getInstance().isTouchExplorationEnabled()); + return wordView; + } + + private void layoutDebugInfo(final int positionInStrip, final ViewGroup placerView, + final int x) { + final TextView debugInfoView = mDebugInfoViews.get(positionInStrip); + final CharSequence debugInfo = debugInfoView.getText(); + if (debugInfo == null) { + return; + } + placerView.addView(debugInfoView); + debugInfoView.measure( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + final int infoWidth = debugInfoView.getMeasuredWidth(); + final int y = debugInfoView.getMeasuredHeight(); + ViewLayoutUtils.placeViewAt( + debugInfoView, x - infoWidth, y, infoWidth, debugInfoView.getMeasuredHeight()); + } + + private int getSuggestionWidth(final int positionInStrip, final int maxWidth) { + final int paddings = mPadding * mSuggestionsCountInStrip; + final int dividers = mDividerWidth * (mSuggestionsCountInStrip - 1); + final int availableWidth = maxWidth - paddings - dividers; + return (int)(availableWidth * getSuggestionWeight(positionInStrip)); + } + + private float getSuggestionWeight(final int positionInStrip) { + if (positionInStrip == mCenterPositionInStrip) { + return mCenterSuggestionWeight; + } + // TODO: Revisit this for cases of 5 or more suggestions + return (1.0f - mCenterSuggestionWeight) / (mSuggestionsCountInStrip - 1); + } + + private int setupWordViewsAndReturnStartIndexOfMoreSuggestions( + final SuggestedWords suggestedWords, final int maxSuggestionInStrip) { + // Clear all suggestions first + for (int positionInStrip = 0; positionInStrip < maxSuggestionInStrip; ++positionInStrip) { + final TextView wordView = mWordViews.get(positionInStrip); + wordView.setText(null); + wordView.setTag(null); + // Make this inactive for touches in {@link #layoutWord(int,int)}. + if (SuggestionStripView.DBG) { + mDebugInfoViews.get(positionInStrip).setText(null); + } + } + int count = 0; + int indexInSuggestedWords; + for (indexInSuggestedWords = 0; indexInSuggestedWords < suggestedWords.size() + && count < maxSuggestionInStrip; indexInSuggestedWords++) { + final int positionInStrip = + getPositionInSuggestionStrip(indexInSuggestedWords, suggestedWords); + if (positionInStrip < 0) { + continue; + } + final TextView wordView = mWordViews.get(positionInStrip); + // {@link TextView#getTag()} is used to get the index in suggestedWords at + // {@link SuggestionStripView#onClick(View)}. + wordView.setTag(indexInSuggestedWords); + wordView.setText(getStyledSuggestedWord(suggestedWords, indexInSuggestedWords)); + wordView.setTextColor(getSuggestionTextColor(suggestedWords, indexInSuggestedWords)); + if (SuggestionStripView.DBG) { + mDebugInfoViews.get(positionInStrip).setText( + suggestedWords.getDebugString(indexInSuggestedWords)); + } + count++; + } + return indexInSuggestedWords; + } + + private int layoutPunctuationsAndReturnStartIndexOfMoreSuggestions( + final PunctuationSuggestions punctuationSuggestions, final ViewGroup stripView) { + final int countInStrip = Math.min(punctuationSuggestions.size(), PUNCTUATIONS_IN_STRIP); + for (int positionInStrip = 0; positionInStrip < countInStrip; positionInStrip++) { + if (positionInStrip != 0) { + // Add divider if this isn't the left most suggestion in suggestions strip. + addDivider(stripView, mDividerViews.get(positionInStrip)); + } + + final TextView wordView = mWordViews.get(positionInStrip); + final String punctuation = punctuationSuggestions.getLabel(positionInStrip); + // {@link TextView#getTag()} is used to get the index in suggestedWords at + // {@link SuggestionStripView#onClick(View)}. + wordView.setTag(positionInStrip); + wordView.setText(punctuation); + wordView.setContentDescription(punctuation); + wordView.setTextScaleX(1.0f); + wordView.setCompoundDrawables(null, null, null, null); + wordView.setTextColor(mColorAutoCorrect); + stripView.addView(wordView); + setLayoutWeight(wordView, 1.0f, mSuggestionsStripHeight); + } + mMoreSuggestionsAvailable = (punctuationSuggestions.size() > countInStrip); + return countInStrip; + } + + public void layoutImportantNotice(final View importantNoticeStrip, + final String importantNoticeTitle) { + final TextView titleView = (TextView)importantNoticeStrip.findViewById( + R.id.important_notice_title); + final int width = titleView.getWidth() - titleView.getPaddingLeft() + - titleView.getPaddingRight(); + titleView.setTextColor(mColorAutoCorrect); + titleView.setText(importantNoticeTitle); // TextView.setText() resets text scale x to 1.0. + final float titleScaleX = getTextScaleX(importantNoticeTitle, width, titleView.getPaint()); + titleView.setTextScaleX(titleScaleX); + } + + static void setLayoutWeight(final View v, final float weight, final int height) { + final ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp instanceof LinearLayout.LayoutParams) { + final LinearLayout.LayoutParams llp = (LinearLayout.LayoutParams)lp; + llp.weight = weight; + llp.width = 0; + llp.height = height; + } + } + + private static float getTextScaleX(@Nullable final CharSequence text, final int maxWidth, + final TextPaint paint) { + paint.setTextScaleX(1.0f); + final int width = getTextWidth(text, paint); + if (width <= maxWidth || maxWidth <= 0) { + return 1.0f; + } + return maxWidth / (float) width; + } + + @Nullable + private static CharSequence getEllipsizedTextWithSettingScaleX( + @Nullable final CharSequence text, final int maxWidth, @Nonnull final TextPaint paint) { + if (text == null) { + return null; + } + final float scaleX = getTextScaleX(text, maxWidth, paint); + if (scaleX >= MIN_TEXT_XSCALE) { + paint.setTextScaleX(scaleX); + return text; + } + + // <code>text</code> must be ellipsized with minimum text scale x. + paint.setTextScaleX(MIN_TEXT_XSCALE); + final boolean hasBoldStyle = hasStyleSpan(text, BOLD_SPAN); + final boolean hasUnderlineStyle = hasStyleSpan(text, UNDERLINE_SPAN); + // TextUtils.ellipsize erases any span object existed after ellipsized point. + // We have to restore these spans afterward. + final CharSequence ellipsizedText = TextUtils.ellipsize( + text, paint, maxWidth, TextUtils.TruncateAt.MIDDLE); + if (!hasBoldStyle && !hasUnderlineStyle) { + return ellipsizedText; + } + final Spannable spannableText = (ellipsizedText instanceof Spannable) + ? (Spannable)ellipsizedText : new SpannableString(ellipsizedText); + if (hasBoldStyle) { + addStyleSpan(spannableText, BOLD_SPAN); + } + if (hasUnderlineStyle) { + addStyleSpan(spannableText, UNDERLINE_SPAN); + } + return spannableText; + } + + private static boolean hasStyleSpan(@Nullable final CharSequence text, + final CharacterStyle style) { + if (text instanceof Spanned) { + return ((Spanned)text).getSpanStart(style) >= 0; + } + return false; + } + + private static void addStyleSpan(@Nonnull final Spannable text, final CharacterStyle style) { + text.removeSpan(style); + text.setSpan(style, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + private static int getTextWidth(@Nullable final CharSequence text, final TextPaint paint) { + if (TextUtils.isEmpty(text)) { + return 0; + } + final int length = text.length(); + final float[] widths = new float[length]; + final int count; + final Typeface savedTypeface = paint.getTypeface(); + try { + paint.setTypeface(getTextTypeface(text)); + count = paint.getTextWidths(text, 0, length, widths); + } finally { + paint.setTypeface(savedTypeface); + } + int width = 0; + for (int i = 0; i < count; i++) { + width += Math.round(widths[i] + 0.5f); + } + return width; + } + + private static Typeface getTextTypeface(@Nullable final CharSequence text) { + return hasStyleSpan(text, BOLD_SPAN) ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripView.java b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripView.java new file mode 100644 index 000000000..9e75a8f8d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripView.java @@ -0,0 +1,491 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.suggestions; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import androidx.core.view.ViewCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.GestureDetector; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.widget.ImageButton; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.kelar.inputmethod.accessibility.AccessibilityUtils; +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.keyboard.MainKeyboardView; +import org.kelar.inputmethod.keyboard.MoreKeysPanel; +import org.kelar.inputmethod.latin.AudioAndHapticFeedbackManager; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.define.DebugFlags; +import org.kelar.inputmethod.latin.settings.Settings; +import org.kelar.inputmethod.latin.settings.SettingsValues; +import org.kelar.inputmethod.latin.suggestions.MoreSuggestionsView.MoreSuggestionsListener; +import org.kelar.inputmethod.latin.utils.ImportantNoticeUtils; + +import java.util.ArrayList; + +public final class SuggestionStripView extends RelativeLayout implements OnClickListener, + OnLongClickListener { + public interface Listener { + public void showImportantNoticeContents(); + public void pickSuggestionManually(SuggestedWordInfo word); + public void onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat); + } + + static final boolean DBG = DebugFlags.DEBUG_ENABLED; + private static final float DEBUG_INFO_TEXT_SIZE_IN_DIP = 6.0f; + + private final ViewGroup mSuggestionsStrip; + private final ImageButton mVoiceKey; + private final View mImportantNoticeStrip; + MainKeyboardView mMainKeyboardView; + + private final View mMoreSuggestionsContainer; + private final MoreSuggestionsView mMoreSuggestionsView; + private final MoreSuggestions.Builder mMoreSuggestionsBuilder; + + private final ArrayList<TextView> mWordViews = new ArrayList<>(); + private final ArrayList<TextView> mDebugInfoViews = new ArrayList<>(); + private final ArrayList<View> mDividerViews = new ArrayList<>(); + + Listener mListener; + private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); + private int mStartIndexOfMoreSuggestions; + + private final SuggestionStripLayoutHelper mLayoutHelper; + private final StripVisibilityGroup mStripVisibilityGroup; + + private static class StripVisibilityGroup { + private final View mSuggestionStripView; + private final View mSuggestionsStrip; + private final View mImportantNoticeStrip; + + public StripVisibilityGroup(final View suggestionStripView, + final ViewGroup suggestionsStrip, final View importantNoticeStrip) { + mSuggestionStripView = suggestionStripView; + mSuggestionsStrip = suggestionsStrip; + mImportantNoticeStrip = importantNoticeStrip; + showSuggestionsStrip(); + } + + public void setLayoutDirection(final boolean isRtlLanguage) { + final int layoutDirection = isRtlLanguage ? ViewCompat.LAYOUT_DIRECTION_RTL + : ViewCompat.LAYOUT_DIRECTION_LTR; + ViewCompat.setLayoutDirection(mSuggestionStripView, layoutDirection); + ViewCompat.setLayoutDirection(mSuggestionsStrip, layoutDirection); + ViewCompat.setLayoutDirection(mImportantNoticeStrip, layoutDirection); + } + + public void showSuggestionsStrip() { + mSuggestionsStrip.setVisibility(VISIBLE); + mImportantNoticeStrip.setVisibility(INVISIBLE); + } + + public void showImportantNoticeStrip() { + mSuggestionsStrip.setVisibility(INVISIBLE); + mImportantNoticeStrip.setVisibility(VISIBLE); + } + + public boolean isShowingImportantNoticeStrip() { + return mImportantNoticeStrip.getVisibility() == VISIBLE; + } + } + + /** + * Construct a {@link SuggestionStripView} for showing suggestions to be picked by the user. + * @param context + * @param attrs + */ + public SuggestionStripView(final Context context, final AttributeSet attrs) { + this(context, attrs, R.attr.suggestionStripViewStyle); + } + + public SuggestionStripView(final Context context, final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.suggestions_strip, this); + + mSuggestionsStrip = (ViewGroup)findViewById(R.id.suggestions_strip); + mVoiceKey = (ImageButton)findViewById(R.id.suggestions_strip_voice_key); + mImportantNoticeStrip = findViewById(R.id.important_notice_strip); + mStripVisibilityGroup = new StripVisibilityGroup(this, mSuggestionsStrip, + mImportantNoticeStrip); + + for (int pos = 0; pos < SuggestedWords.MAX_SUGGESTIONS; pos++) { + final TextView word = new TextView(context, null, R.attr.suggestionWordStyle); + word.setContentDescription(getResources().getString(R.string.spoken_empty_suggestion)); + word.setOnClickListener(this); + word.setOnLongClickListener(this); + mWordViews.add(word); + final View divider = inflater.inflate(R.layout.suggestion_divider, null); + mDividerViews.add(divider); + final TextView info = new TextView(context, null, R.attr.suggestionWordStyle); + info.setTextColor(Color.WHITE); + info.setTextSize(TypedValue.COMPLEX_UNIT_DIP, DEBUG_INFO_TEXT_SIZE_IN_DIP); + mDebugInfoViews.add(info); + } + + mLayoutHelper = new SuggestionStripLayoutHelper( + context, attrs, defStyle, mWordViews, mDividerViews, mDebugInfoViews); + + mMoreSuggestionsContainer = inflater.inflate(R.layout.more_suggestions, null); + mMoreSuggestionsView = (MoreSuggestionsView)mMoreSuggestionsContainer + .findViewById(R.id.more_suggestions_view); + mMoreSuggestionsBuilder = new MoreSuggestions.Builder(context, mMoreSuggestionsView); + + final Resources res = context.getResources(); + mMoreSuggestionsModalTolerance = res.getDimensionPixelOffset( + R.dimen.config_more_suggestions_modal_tolerance); + mMoreSuggestionsSlidingDetector = new GestureDetector( + context, mMoreSuggestionsSlidingListener); + + final TypedArray keyboardAttr = context.obtainStyledAttributes(attrs, + R.styleable.Keyboard, defStyle, R.style.SuggestionStripView); + final Drawable iconVoice = keyboardAttr.getDrawable(R.styleable.Keyboard_iconShortcutKey); + keyboardAttr.recycle(); + mVoiceKey.setImageDrawable(iconVoice); + mVoiceKey.setOnClickListener(this); + } + + /** + * A connection back to the input method. + * @param listener + */ + public void setListener(final Listener listener, final View inputView) { + mListener = listener; + mMainKeyboardView = (MainKeyboardView)inputView.findViewById(R.id.keyboard_view); + } + + public void updateVisibility(final boolean shouldBeVisible, final boolean isFullscreenMode) { + final int visibility = shouldBeVisible ? VISIBLE : (isFullscreenMode ? GONE : INVISIBLE); + setVisibility(visibility); + final SettingsValues currentSettingsValues = Settings.getInstance().getCurrent(); + mVoiceKey.setVisibility(currentSettingsValues.mShowsVoiceInputKey ? VISIBLE : INVISIBLE); + } + + public void setSuggestions(final SuggestedWords suggestedWords, final boolean isRtlLanguage) { + clear(); + mStripVisibilityGroup.setLayoutDirection(isRtlLanguage); + mSuggestedWords = suggestedWords; + mStartIndexOfMoreSuggestions = mLayoutHelper.layoutAndReturnStartIndexOfMoreSuggestions( + getContext(), mSuggestedWords, mSuggestionsStrip, this); + mStripVisibilityGroup.showSuggestionsStrip(); + } + + public void setMoreSuggestionsHeight(final int remainingHeight) { + mLayoutHelper.setMoreSuggestionsHeight(remainingHeight); + } + + // This method checks if we should show the important notice (checks on permanent storage if + // it has been shown once already or not, and if in the setup wizard). If applicable, it shows + // the notice. In all cases, it returns true if it was shown, false otherwise. + public boolean maybeShowImportantNoticeTitle() { + final SettingsValues currentSettingsValues = Settings.getInstance().getCurrent(); + if (!ImportantNoticeUtils.shouldShowImportantNotice(getContext(), currentSettingsValues)) { + return false; + } + if (getWidth() <= 0) { + return false; + } + final String importantNoticeTitle = ImportantNoticeUtils.getSuggestContactsNoticeTitle( + getContext()); + if (TextUtils.isEmpty(importantNoticeTitle)) { + return false; + } + if (isShowingMoreSuggestionPanel()) { + dismissMoreSuggestionsPanel(); + } + mLayoutHelper.layoutImportantNotice(mImportantNoticeStrip, importantNoticeTitle); + mStripVisibilityGroup.showImportantNoticeStrip(); + mImportantNoticeStrip.setOnClickListener(this); + return true; + } + + public void clear() { + mSuggestionsStrip.removeAllViews(); + removeAllDebugInfoViews(); + mStripVisibilityGroup.showSuggestionsStrip(); + dismissMoreSuggestionsPanel(); + } + + private void removeAllDebugInfoViews() { + // The debug info views may be placed as children views of this {@link SuggestionStripView}. + for (final View debugInfoView : mDebugInfoViews) { + final ViewParent parent = debugInfoView.getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup)parent).removeView(debugInfoView); + } + } + } + + private final MoreSuggestionsListener mMoreSuggestionsListener = new MoreSuggestionsListener() { + @Override + public void onSuggestionSelected(final SuggestedWordInfo wordInfo) { + mListener.pickSuggestionManually(wordInfo); + dismissMoreSuggestionsPanel(); + } + + @Override + public void onCancelInput() { + dismissMoreSuggestionsPanel(); + } + }; + + private final MoreKeysPanel.Controller mMoreSuggestionsController = + new MoreKeysPanel.Controller() { + @Override + public void onDismissMoreKeysPanel() { + mMainKeyboardView.onDismissMoreKeysPanel(); + } + + @Override + public void onShowMoreKeysPanel(final MoreKeysPanel panel) { + mMainKeyboardView.onShowMoreKeysPanel(panel); + } + + @Override + public void onCancelMoreKeysPanel() { + dismissMoreSuggestionsPanel(); + } + }; + + public boolean isShowingMoreSuggestionPanel() { + return mMoreSuggestionsView.isShowingInParent(); + } + + public void dismissMoreSuggestionsPanel() { + mMoreSuggestionsView.dismissMoreKeysPanel(); + } + + @Override + public boolean onLongClick(final View view) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback( + Constants.NOT_A_CODE, this); + return showMoreSuggestions(); + } + + boolean showMoreSuggestions() { + final Keyboard parentKeyboard = mMainKeyboardView.getKeyboard(); + if (parentKeyboard == null) { + return false; + } + final SuggestionStripLayoutHelper layoutHelper = mLayoutHelper; + if (mSuggestedWords.size() <= mStartIndexOfMoreSuggestions) { + return false; + } + final int stripWidth = getWidth(); + final View container = mMoreSuggestionsContainer; + final int maxWidth = stripWidth - container.getPaddingLeft() - container.getPaddingRight(); + final MoreSuggestions.Builder builder = mMoreSuggestionsBuilder; + builder.layout(mSuggestedWords, mStartIndexOfMoreSuggestions, maxWidth, + (int)(maxWidth * layoutHelper.mMinMoreSuggestionsWidth), + layoutHelper.getMaxMoreSuggestionsRow(), parentKeyboard); + mMoreSuggestionsView.setKeyboard(builder.build()); + container.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + final MoreKeysPanel moreKeysPanel = mMoreSuggestionsView; + final int pointX = stripWidth / 2; + final int pointY = -layoutHelper.mMoreSuggestionsBottomGap; + moreKeysPanel.showMoreKeysPanel(this, mMoreSuggestionsController, pointX, pointY, + mMoreSuggestionsListener); + mOriginX = mLastX; + mOriginY = mLastY; + for (int i = 0; i < mStartIndexOfMoreSuggestions; i++) { + mWordViews.get(i).setPressed(false); + } + return true; + } + + // Working variables for {@link onInterceptTouchEvent(MotionEvent)} and + // {@link onTouchEvent(MotionEvent)}. + private int mLastX; + private int mLastY; + private int mOriginX; + private int mOriginY; + private final int mMoreSuggestionsModalTolerance; + private boolean mNeedsToTransformTouchEventToHoverEvent; + private boolean mIsDispatchingHoverEventToMoreSuggestions; + private final GestureDetector mMoreSuggestionsSlidingDetector; + private final GestureDetector.OnGestureListener mMoreSuggestionsSlidingListener = + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(MotionEvent down, MotionEvent me, float deltaX, float deltaY) { + if (down == null) { + return false; + } + final float dy = me.getY() - down.getY(); + if (deltaY > 0 && dy < 0) { + return showMoreSuggestions(); + } + return false; + } + }; + + @Override + public boolean onInterceptTouchEvent(final MotionEvent me) { + if (mStripVisibilityGroup.isShowingImportantNoticeStrip()) { + return false; + } + // Detecting sliding up finger to show {@link MoreSuggestionsView}. + if (!mMoreSuggestionsView.isShowingInParent()) { + mLastX = (int)me.getX(); + mLastY = (int)me.getY(); + return mMoreSuggestionsSlidingDetector.onTouchEvent(me); + } + if (mMoreSuggestionsView.isInModalMode()) { + return false; + } + + final int action = me.getAction(); + final int index = me.getActionIndex(); + final int x = (int)me.getX(index); + final int y = (int)me.getY(index); + if (Math.abs(x - mOriginX) >= mMoreSuggestionsModalTolerance + || mOriginY - y >= mMoreSuggestionsModalTolerance) { + // Decided to be in the sliding suggestion mode only when the touch point has been moved + // upward. Further {@link MotionEvent}s will be delivered to + // {@link #onTouchEvent(MotionEvent)}. + mNeedsToTransformTouchEventToHoverEvent = + AccessibilityUtils.getInstance().isTouchExplorationEnabled(); + mIsDispatchingHoverEventToMoreSuggestions = false; + return true; + } + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + // Decided to be in the modal input mode. + mMoreSuggestionsView.setModalMode(); + } + return false; + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(final AccessibilityEvent event) { + // Don't populate accessibility event with suggested words and voice key. + return true; + } + + @Override + public boolean onTouchEvent(final MotionEvent me) { + if (!mMoreSuggestionsView.isShowingInParent()) { + // Ignore any touch event while more suggestions panel hasn't been shown. + // Detecting sliding up is done at {@link #onInterceptTouchEvent}. + return true; + } + // In the sliding input mode. {@link MotionEvent} should be forwarded to + // {@link MoreSuggestionsView}. + final int index = me.getActionIndex(); + final int x = mMoreSuggestionsView.translateX((int)me.getX(index)); + final int y = mMoreSuggestionsView.translateY((int)me.getY(index)); + me.setLocation(x, y); + if (!mNeedsToTransformTouchEventToHoverEvent) { + mMoreSuggestionsView.onTouchEvent(me); + return true; + } + // In sliding suggestion mode with accessibility mode on, a touch event should be + // transformed to a hover event. + final int width = mMoreSuggestionsView.getWidth(); + final int height = mMoreSuggestionsView.getHeight(); + final boolean onMoreSuggestions = (x >= 0 && x < width && y >= 0 && y < height); + if (!onMoreSuggestions && !mIsDispatchingHoverEventToMoreSuggestions) { + // Just drop this touch event because dispatching hover event isn't started yet and + // the touch event isn't on {@link MoreSuggestionsView}. + return true; + } + final int hoverAction; + if (onMoreSuggestions && !mIsDispatchingHoverEventToMoreSuggestions) { + // Transform this touch event to a hover enter event and start dispatching a hover + // event to {@link MoreSuggestionsView}. + mIsDispatchingHoverEventToMoreSuggestions = true; + hoverAction = MotionEvent.ACTION_HOVER_ENTER; + } else if (me.getActionMasked() == MotionEvent.ACTION_UP) { + // Transform this touch event to a hover exit event and stop dispatching a hover event + // after this. + mIsDispatchingHoverEventToMoreSuggestions = false; + mNeedsToTransformTouchEventToHoverEvent = false; + hoverAction = MotionEvent.ACTION_HOVER_EXIT; + } else { + // Transform this touch event to a hover move event. + hoverAction = MotionEvent.ACTION_HOVER_MOVE; + } + me.setAction(hoverAction); + mMoreSuggestionsView.onHoverEvent(me); + return true; + } + + @Override + public void onClick(final View view) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback( + Constants.CODE_UNSPECIFIED, this); + if (view == mImportantNoticeStrip) { + mListener.showImportantNoticeContents(); + return; + } + if (view == mVoiceKey) { + mListener.onCodeInput(Constants.CODE_SHORTCUT, + Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, + false /* isKeyRepeat */); + return; + } + + final Object tag = view.getTag(); + // {@link Integer} tag is set at + // {@link SuggestionStripLayoutHelper#setupWordViewsTextAndColor(SuggestedWords,int)} and + // {@link SuggestionStripLayoutHelper#layoutPunctuationSuggestions(SuggestedWords,ViewGroup} + if (tag instanceof Integer) { + final int index = (Integer) tag; + if (index >= mSuggestedWords.size()) { + return; + } + final SuggestedWordInfo wordInfo = mSuggestedWords.getInfo(index); + mListener.pickSuggestionManually(wordInfo); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + dismissMoreSuggestionsPanel(); + } + + @Override + protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { + // Called by the framework when the size is known. Show the important notice if applicable. + // This may be overriden by showing suggestions later, if applicable. + if (oldw <= 0 && w > 0) { + maybeShowImportantNoticeTitle(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripViewAccessor.java b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripViewAccessor.java new file mode 100644 index 000000000..5af9611cf --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripViewAccessor.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.suggestions; + +import org.kelar.inputmethod.latin.SuggestedWords; + +/** + * An object that gives basic control of a suggestion strip and some info on it. + */ +public interface SuggestionStripViewAccessor { + public void setNeutralSuggestionStrip(); + public void showSuggestionStrip(final SuggestedWords suggestedWords); +} diff --git a/java/src/org/kelar/inputmethod/latin/touchinputconsumer/GestureConsumer.java b/java/src/org/kelar/inputmethod/latin/touchinputconsumer/GestureConsumer.java new file mode 100644 index 000000000..26a22e1b6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/touchinputconsumer/GestureConsumer.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.touchinputconsumer; + +import android.view.inputmethod.EditorInfo; + +import org.kelar.inputmethod.keyboard.Keyboard; +import org.kelar.inputmethod.latin.DictionaryFacilitator; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.common.InputPointers; +import org.kelar.inputmethod.latin.inputlogic.PrivateCommandPerformer; + +import java.util.Locale; + +/** + * Stub for GestureConsumer. + * <br> + * The methods of this class should only be called from a single thread, e.g., + * the UI Thread. + */ +@SuppressWarnings("unused") +public class GestureConsumer { + public static final GestureConsumer NULL_GESTURE_CONSUMER = + new GestureConsumer(); + + public static GestureConsumer newInstance( + final EditorInfo editorInfo, final PrivateCommandPerformer commandPerformer, + final Locale locale, final Keyboard keyboard) { + return GestureConsumer.NULL_GESTURE_CONSUMER; + } + + private GestureConsumer() { + } + + public boolean willConsume() { + return false; + } + + public void onInit(final Locale locale, final Keyboard keyboard) { + } + + public void onGestureStarted(final Locale locale, final Keyboard keyboard) { + } + + public void onGestureCanceled() { + } + + public void onGestureCompleted(final InputPointers inputPointers) { + } + + public void onImeSuggestionsProcessed(final SuggestedWords suggestedWords, + final int composingStart, final int composingLength, + final DictionaryFacilitator dictionaryFacilitator) { + } +} diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordContents.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordContents.java new file mode 100644 index 000000000..f214eb82a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordContents.java @@ -0,0 +1,286 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.provider.UserDictionary; +import android.text.TextUtils; +import android.view.View; +import android.widget.EditText; + +import org.kelar.inputmethod.compat.UserDictionaryCompatUtils; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import java.util.ArrayList; +import java.util.Locale; +import java.util.TreeSet; + +import javax.annotation.Nullable; + +// Caveat: This class is basically taken from +// packages/apps/Settings/src/com/android/settings/inputmethod/UserDictionaryAddWordContents.java +// in order to deal with some devices that have issues with the user dictionary handling + +/** + * A container class to factor common code to UserDictionaryAddWordFragment + * and UserDictionaryAddWordActivity. + */ +public class UserDictionaryAddWordContents { + public static final String EXTRA_MODE = "mode"; + public static final String EXTRA_WORD = "word"; + public static final String EXTRA_SHORTCUT = "shortcut"; + public static final String EXTRA_LOCALE = "locale"; + public static final String EXTRA_ORIGINAL_WORD = "originalWord"; + public static final String EXTRA_ORIGINAL_SHORTCUT = "originalShortcut"; + + public static final int MODE_EDIT = 0; + public static final int MODE_INSERT = 1; + + /* package */ static final int CODE_WORD_ADDED = 0; + /* package */ static final int CODE_CANCEL = 1; + /* package */ static final int CODE_ALREADY_PRESENT = 2; + + private static final int FREQUENCY_FOR_USER_DICTIONARY_ADDS = 250; + + private final int mMode; // Either MODE_EDIT or MODE_INSERT + private final EditText mWordEditText; + private final EditText mShortcutEditText; + private String mLocale; + private final String mOldWord; + private final String mOldShortcut; + private String mSavedWord; + private String mSavedShortcut; + + /* package */ UserDictionaryAddWordContents(final View view, final Bundle args) { + mWordEditText = (EditText)view.findViewById(R.id.user_dictionary_add_word_text); + mShortcutEditText = (EditText)view.findViewById(R.id.user_dictionary_add_shortcut); + if (!UserDictionarySettings.IS_SHORTCUT_API_SUPPORTED) { + mShortcutEditText.setVisibility(View.GONE); + view.findViewById(R.id.user_dictionary_add_shortcut_label).setVisibility(View.GONE); + } + final String word = args.getString(EXTRA_WORD); + if (null != word) { + mWordEditText.setText(word); + // Use getText in case the edit text modified the text we set. This happens when + // it's too long to be edited. + mWordEditText.setSelection(mWordEditText.getText().length()); + } + final String shortcut; + if (UserDictionarySettings.IS_SHORTCUT_API_SUPPORTED) { + shortcut = args.getString(EXTRA_SHORTCUT); + if (null != shortcut && null != mShortcutEditText) { + mShortcutEditText.setText(shortcut); + } + mOldShortcut = args.getString(EXTRA_SHORTCUT); + } else { + shortcut = null; + mOldShortcut = null; + } + mMode = args.getInt(EXTRA_MODE); // default return value for #getInt() is 0 = MODE_EDIT + mOldWord = args.getString(EXTRA_WORD); + updateLocale(args.getString(EXTRA_LOCALE)); + } + + /* package */ UserDictionaryAddWordContents(final View view, + final UserDictionaryAddWordContents oldInstanceToBeEdited) { + mWordEditText = (EditText)view.findViewById(R.id.user_dictionary_add_word_text); + mShortcutEditText = (EditText)view.findViewById(R.id.user_dictionary_add_shortcut); + mMode = MODE_EDIT; + mOldWord = oldInstanceToBeEdited.mSavedWord; + mOldShortcut = oldInstanceToBeEdited.mSavedShortcut; + updateLocale(mLocale); + } + + // locale may be null, this means default locale + // It may also be the empty string, which means "all locales" + /* package */ void updateLocale(final String locale) { + mLocale = null == locale ? Locale.getDefault().toString() : locale; + } + + /* package */ void saveStateIntoBundle(final Bundle outState) { + outState.putString(EXTRA_WORD, mWordEditText.getText().toString()); + outState.putString(EXTRA_ORIGINAL_WORD, mOldWord); + if (null != mShortcutEditText) { + outState.putString(EXTRA_SHORTCUT, mShortcutEditText.getText().toString()); + } + if (null != mOldShortcut) { + outState.putString(EXTRA_ORIGINAL_SHORTCUT, mOldShortcut); + } + outState.putString(EXTRA_LOCALE, mLocale); + } + + /* package */ void delete(final Context context) { + if (MODE_EDIT == mMode && !TextUtils.isEmpty(mOldWord)) { + // Mode edit: remove the old entry. + final ContentResolver resolver = context.getContentResolver(); + UserDictionarySettings.deleteWord(mOldWord, mOldShortcut, resolver); + } + // If we are in add mode, nothing was added, so we don't need to do anything. + } + + /* package */ + int apply(final Context context, final Bundle outParameters) { + if (null != outParameters) saveStateIntoBundle(outParameters); + final ContentResolver resolver = context.getContentResolver(); + if (MODE_EDIT == mMode && !TextUtils.isEmpty(mOldWord)) { + // Mode edit: remove the old entry. + UserDictionarySettings.deleteWord(mOldWord, mOldShortcut, resolver); + } + final String newWord = mWordEditText.getText().toString(); + final String newShortcut; + if (!UserDictionarySettings.IS_SHORTCUT_API_SUPPORTED) { + newShortcut = null; + } else if (null == mShortcutEditText) { + newShortcut = null; + } else { + final String tmpShortcut = mShortcutEditText.getText().toString(); + if (TextUtils.isEmpty(tmpShortcut)) { + newShortcut = null; + } else { + newShortcut = tmpShortcut; + } + } + if (TextUtils.isEmpty(newWord)) { + // If the word is somehow empty, don't insert it. + return CODE_CANCEL; + } + mSavedWord = newWord; + mSavedShortcut = newShortcut; + // If there is no shortcut, and the word already exists in the database, then we + // should not insert, because either A. the word exists with no shortcut, in which + // case the exact same thing we want to insert is already there, or B. the word + // exists with at least one shortcut, in which case it has priority on our word. + if (TextUtils.isEmpty(newShortcut) && hasWord(newWord, context)) { + return CODE_ALREADY_PRESENT; + } + + // Disallow duplicates. If the same word with no shortcut is defined, remove it; if + // the same word with the same shortcut is defined, remove it; but we don't mind if + // there is the same word with a different, non-empty shortcut. + UserDictionarySettings.deleteWord(newWord, null, resolver); + if (!TextUtils.isEmpty(newShortcut)) { + // If newShortcut is empty we just deleted this, no need to do it again + UserDictionarySettings.deleteWord(newWord, newShortcut, resolver); + } + + // In this class we use the empty string to represent 'all locales' and mLocale cannot + // be null. However the addWord method takes null to mean 'all locales'. + UserDictionaryCompatUtils.addWord(context, newWord.toString(), + FREQUENCY_FOR_USER_DICTIONARY_ADDS, newShortcut, TextUtils.isEmpty(mLocale) ? + null : LocaleUtils.constructLocaleFromString(mLocale)); + + return CODE_WORD_ADDED; + } + + private static final String[] HAS_WORD_PROJECTION = { UserDictionary.Words.WORD }; + private static final String HAS_WORD_SELECTION_ONE_LOCALE = UserDictionary.Words.WORD + + "=? AND " + UserDictionary.Words.LOCALE + "=?"; + private static final String HAS_WORD_SELECTION_ALL_LOCALES = UserDictionary.Words.WORD + + "=? AND " + UserDictionary.Words.LOCALE + " is null"; + private boolean hasWord(final String word, final Context context) { + final Cursor cursor; + // mLocale == "" indicates this is an entry for all languages. Here, mLocale can't + // be null at all (it's ensured by the updateLocale method). + if ("".equals(mLocale)) { + cursor = context.getContentResolver().query(UserDictionary.Words.CONTENT_URI, + HAS_WORD_PROJECTION, HAS_WORD_SELECTION_ALL_LOCALES, + new String[] { word }, null /* sort order */); + } else { + cursor = context.getContentResolver().query(UserDictionary.Words.CONTENT_URI, + HAS_WORD_PROJECTION, HAS_WORD_SELECTION_ONE_LOCALE, + new String[] { word, mLocale }, null /* sort order */); + } + try { + if (null == cursor) return false; + return cursor.getCount() > 0; + } finally { + if (null != cursor) cursor.close(); + } + } + + public static class LocaleRenderer { + private final String mLocaleString; + private final String mDescription; + + public LocaleRenderer(final Context context, @Nullable final String localeString) { + mLocaleString = localeString; + if (null == localeString) { + mDescription = context.getString(R.string.user_dict_settings_more_languages); + } else if ("".equals(localeString)) { + mDescription = context.getString(R.string.user_dict_settings_all_languages); + } else { + mDescription = LocaleUtils.constructLocaleFromString(localeString).getDisplayName(); + } + } + @Override + public String toString() { + return mDescription; + } + public String getLocaleString() { + return mLocaleString; + } + // "More languages..." is null ; "All languages" is the empty string. + public boolean isMoreLanguages() { + return null == mLocaleString; + } + } + + private static void addLocaleDisplayNameToList(final Context context, + final ArrayList<LocaleRenderer> list, final String locale) { + if (null != locale) { + list.add(new LocaleRenderer(context, locale)); + } + } + + // Helper method to get the list of locales to display for this word + public ArrayList<LocaleRenderer> getLocalesList(final Activity activity) { + final TreeSet<String> locales = UserDictionaryList.getUserDictionaryLocalesSet(activity); + // Remove our locale if it's in, because we're always gonna put it at the top + locales.remove(mLocale); // mLocale may not be null + final String systemLocale = Locale.getDefault().toString(); + // The system locale should be inside. We want it at the 2nd spot. + locales.remove(systemLocale); // system locale may not be null + locales.remove(""); // Remove the empty string if it's there + final ArrayList<LocaleRenderer> localesList = new ArrayList<>(); + // Add the passed locale, then the system locale at the top of the list. Add an + // "all languages" entry at the bottom of the list. + addLocaleDisplayNameToList(activity, localesList, mLocale); + if (!systemLocale.equals(mLocale)) { + addLocaleDisplayNameToList(activity, localesList, systemLocale); + } + for (final String l : locales) { + // TODO: sort in unicode order + addLocaleDisplayNameToList(activity, localesList, l); + } + if (!"".equals(mLocale)) { + // If mLocale is "", then we already inserted the "all languages" item, so don't do it + addLocaleDisplayNameToList(activity, localesList, ""); // meaning: all languages + } + localesList.add(new LocaleRenderer(activity, null)); // meaning: select another locale + return localesList; + } + + public String getCurrentUserDictionaryLocale() { + return mLocale; + } +} + diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordFragment.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordFragment.java new file mode 100644 index 000000000..33fa4b84d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordFragment.java @@ -0,0 +1,179 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryAddWordContents.LocaleRenderer; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryLocalePicker.LocationChangedListener; + +import android.app.Fragment; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; + +import java.util.ArrayList; +import java.util.Locale; + +// Caveat: This class is basically taken from +// packages/apps/Settings/src/com/android/settings/inputmethod/UserDictionaryAddWordFragment.java +// in order to deal with some devices that have issues with the user dictionary handling + +/** + * Fragment to add a word/shortcut to the user dictionary. + * + * As opposed to the UserDictionaryActivity, this is only invoked within Settings + * from the UserDictionarySettings. + */ +public class UserDictionaryAddWordFragment extends Fragment + implements AdapterView.OnItemSelectedListener, LocationChangedListener { + + private static final int OPTIONS_MENU_ADD = Menu.FIRST; + private static final int OPTIONS_MENU_DELETE = Menu.FIRST + 1; + + private UserDictionaryAddWordContents mContents; + private View mRootView; + private boolean mIsDeleting = false; + + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + setHasOptionsMenu(true); + getActivity().getActionBar().setTitle(R.string.edit_personal_dictionary); + // Keep the instance so that we remember mContents when configuration changes (eg rotation) + setRetainInstance(true); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedState) { + mRootView = inflater.inflate(R.layout.user_dictionary_add_word_fullscreen, null); + mIsDeleting = false; + // If we have a non-null mContents object, it's the old value before a configuration + // change (eg rotation) so we need to use its values. Otherwise, read from the arguments. + if (null == mContents) { + mContents = new UserDictionaryAddWordContents(mRootView, getArguments()); + } else { + // We create a new mContents object to account for the new situation : a word has + // been added to the user dictionary when we started rotating, and we are now editing + // it. That means in particular if the word undergoes any change, the old version should + // be updated, so the mContents object needs to switch to EDIT mode if it was in + // INSERT mode. + mContents = new UserDictionaryAddWordContents(mRootView, + mContents /* oldInstanceToBeEdited */); + } + getActivity().getActionBar().setSubtitle(UserDictionarySettingsUtils.getLocaleDisplayName( + getActivity(), mContents.getCurrentUserDictionaryLocale())); + return mRootView; + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + final MenuItem actionItemAdd = menu.add(0, OPTIONS_MENU_ADD, 0, + R.string.user_dict_settings_add_menu_title).setIcon(R.drawable.ic_menu_add); + actionItemAdd.setShowAsAction( + MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT); + final MenuItem actionItemDelete = menu.add(0, OPTIONS_MENU_DELETE, 0, + R.string.user_dict_settings_delete).setIcon(android.R.drawable.ic_menu_delete); + actionItemDelete.setShowAsAction( + MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT); + } + + /** + * Callback for the framework when a menu option is pressed. + * + * @param item the item that was pressed + * @return false to allow normal menu processing to proceed, true to consume it here + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == OPTIONS_MENU_ADD) { + // added the entry in "onPause" + getActivity().onBackPressed(); + return true; + } + if (item.getItemId() == OPTIONS_MENU_DELETE) { + mContents.delete(getActivity()); + mIsDeleting = true; + getActivity().onBackPressed(); + return true; + } + return false; + } + + @Override + public void onResume() { + super.onResume(); + // We are being shown: display the word + updateSpinner(); + } + + private void updateSpinner() { + final ArrayList<LocaleRenderer> localesList = mContents.getLocalesList(getActivity()); + + final Spinner localeSpinner = + (Spinner)mRootView.findViewById(R.id.user_dictionary_add_locale); + final ArrayAdapter<LocaleRenderer> adapter = new ArrayAdapter<>( + getActivity(), android.R.layout.simple_spinner_item, localesList); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + localeSpinner.setAdapter(adapter); + localeSpinner.setOnItemSelectedListener(this); + } + + @Override + public void onPause() { + super.onPause(); + // We are being hidden: commit changes to the user dictionary, unless we were deleting it + if (!mIsDeleting) { + mContents.apply(getActivity(), null); + } + } + + @Override + public void onItemSelected(final AdapterView<?> parent, final View view, final int pos, + final long id) { + final LocaleRenderer locale = (LocaleRenderer)parent.getItemAtPosition(pos); + if (locale.isMoreLanguages()) { + PreferenceActivity preferenceActivity = (PreferenceActivity)getActivity(); + preferenceActivity.startPreferenceFragment(new UserDictionaryLocalePicker(), true); + } else { + mContents.updateLocale(locale.getLocaleString()); + } + } + + @Override + public void onNothingSelected(final AdapterView<?> parent) { + // I'm not sure we can come here, but if we do, that's the right thing to do. + final Bundle args = getArguments(); + mContents.updateLocale(args.getString(UserDictionaryAddWordContents.EXTRA_LOCALE)); + } + + // Called by the locale picker + @Override + public void onLocaleSelected(final Locale locale) { + mContents.updateLocale(locale.toString()); + getActivity().onBackPressed(); + } +} + diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryList.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryList.java new file mode 100644 index 000000000..7fd5825ed --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryList.java @@ -0,0 +1,165 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.provider.UserDictionary; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; + +import javax.annotation.Nullable; + +// Caveat: This class is basically taken from +// packages/apps/Settings/src/com/android/settings/inputmethod/UserDictionaryList.java +// in order to deal with some devices that have issues with the user dictionary handling + +public class UserDictionaryList extends PreferenceFragment { + + public static final String USER_DICTIONARY_SETTINGS_INTENT_ACTION = + "android.settings.USER_DICTIONARY_SETTINGS"; + + @Override + public void onCreate(final Bundle icicle) { + super.onCreate(icicle); + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(getActivity())); + } + + public static TreeSet<String> getUserDictionaryLocalesSet(final Activity activity) { + final Cursor cursor = activity.getContentResolver().query(UserDictionary.Words.CONTENT_URI, + new String[] { UserDictionary.Words.LOCALE }, + null, null, null); + final TreeSet<String> localeSet = new TreeSet<>(); + if (null == cursor) { + // The user dictionary service is not present or disabled. Return null. + return null; + } + try { + if (cursor.moveToFirst()) { + final int columnIndex = cursor.getColumnIndex(UserDictionary.Words.LOCALE); + do { + final String locale = cursor.getString(columnIndex); + localeSet.add(null != locale ? locale : ""); + } while (cursor.moveToNext()); + } + } finally { + cursor.close(); + } + if (!UserDictionarySettings.IS_SHORTCUT_API_SUPPORTED) { + // For ICS, we need to show "For all languages" in case that the keyboard locale + // is different from the system locale + localeSet.add(""); + } + + final InputMethodManager imm = + (InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE); + final List<InputMethodInfo> imis = imm.getEnabledInputMethodList(); + for (final InputMethodInfo imi : imis) { + final List<InputMethodSubtype> subtypes = + imm.getEnabledInputMethodSubtypeList( + imi, true /* allowsImplicitlySelectedSubtypes */); + for (InputMethodSubtype subtype : subtypes) { + final String locale = subtype.getLocale(); + if (!TextUtils.isEmpty(locale)) { + localeSet.add(locale); + } + } + } + + // We come here after we have collected locales from existing user dictionary entries and + // enabled subtypes. If we already have the locale-without-country version of the system + // locale, we don't add the system locale to avoid confusion even though it's technically + // correct to add it. + if (!localeSet.contains(Locale.getDefault().getLanguage().toString())) { + localeSet.add(Locale.getDefault().toString()); + } + + return localeSet; + } + + /** + * Creates the entries that allow the user to go into the user dictionary for each locale. + * @param userDictGroup The group to put the settings in. + */ + protected void createUserDictSettings(final PreferenceGroup userDictGroup) { + final Activity activity = getActivity(); + userDictGroup.removeAll(); + final TreeSet<String> localeSet = + UserDictionaryList.getUserDictionaryLocalesSet(activity); + + if (localeSet.size() > 1) { + // Have an "All languages" entry in the languages list if there are two or more active + // languages + localeSet.add(""); + } + + if (localeSet.isEmpty()) { + userDictGroup.addPreference(createUserDictionaryPreference(null)); + } else { + for (String locale : localeSet) { + userDictGroup.addPreference(createUserDictionaryPreference(locale)); + } + } + } + + /** + * Create a single User Dictionary Preference object, with its parameters set. + * @param localeString The locale for which this user dictionary is for. + * @return The corresponding preference. + */ + protected Preference createUserDictionaryPreference(@Nullable final String localeString) { + final Preference newPref = new Preference(getActivity()); + final Intent intent = new Intent(USER_DICTIONARY_SETTINGS_INTENT_ACTION); + if (null == localeString) { + newPref.setTitle(Locale.getDefault().getDisplayName()); + } else { + if (localeString.isEmpty()) { + newPref.setTitle(getString(R.string.user_dict_settings_all_languages)); + } else { + newPref.setTitle( + LocaleUtils.constructLocaleFromString(localeString).getDisplayName()); + } + intent.putExtra("locale", localeString); + newPref.getExtras().putString("locale", localeString); + } + newPref.setIntent(intent); + newPref.setFragment(UserDictionarySettings.class.getName()); + return newPref; + } + + @Override + public void onResume() { + super.onResume(); + createUserDictSettings(getPreferenceScreen()); + } +} + diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryLocalePicker.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryLocalePicker.java new file mode 100644 index 000000000..12d9140f8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryLocalePicker.java @@ -0,0 +1,36 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import android.app.Fragment; + +import java.util.Locale; + +// Caveat: This class is basically taken from +// packages/apps/Settings/src/com/android/settings/inputmethod/UserDictionaryLocalePicker.java +// in order to deal with some devices that have issues with the user dictionary handling + +public class UserDictionaryLocalePicker extends Fragment { + public UserDictionaryLocalePicker() { + super(); + // TODO: implement + } + + public interface LocationChangedListener { + public void onLocaleSelected(Locale locale); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettings.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettings.java new file mode 100644 index 000000000..d02dbd67c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettings.java @@ -0,0 +1,352 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import org.kelar.inputmethod.latin.R; + +import android.app.ListFragment; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Build; +import android.os.Bundle; +import android.provider.UserDictionary; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AlphabetIndexer; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.SectionIndexer; +import android.widget.SimpleCursorAdapter; +import android.widget.TextView; + +import java.util.Locale; + +// Caveat: This class is basically taken from +// packages/apps/Settings/src/com/android/settings/inputmethod/UserDictionarySettings.java +// in order to deal with some devices that have issues with the user dictionary handling + +public class UserDictionarySettings extends ListFragment { + + public static final boolean IS_SHORTCUT_API_SUPPORTED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + + private static final String[] QUERY_PROJECTION_SHORTCUT_UNSUPPORTED = + { UserDictionary.Words._ID, UserDictionary.Words.WORD}; + private static final String[] QUERY_PROJECTION_SHORTCUT_SUPPORTED = + { UserDictionary.Words._ID, UserDictionary.Words.WORD, UserDictionary.Words.SHORTCUT}; + private static final String[] QUERY_PROJECTION = + IS_SHORTCUT_API_SUPPORTED ? + QUERY_PROJECTION_SHORTCUT_SUPPORTED : QUERY_PROJECTION_SHORTCUT_UNSUPPORTED; + + // The index of the shortcut in the above array. + private static final int INDEX_SHORTCUT = 2; + + private static final String[] ADAPTER_FROM_SHORTCUT_UNSUPPORTED = { + UserDictionary.Words.WORD, + }; + + private static final String[] ADAPTER_FROM_SHORTCUT_SUPPORTED = { + UserDictionary.Words.WORD, UserDictionary.Words.SHORTCUT + }; + + private static final String[] ADAPTER_FROM = IS_SHORTCUT_API_SUPPORTED ? + ADAPTER_FROM_SHORTCUT_SUPPORTED : ADAPTER_FROM_SHORTCUT_UNSUPPORTED; + + private static final int[] ADAPTER_TO_SHORTCUT_UNSUPPORTED = { + android.R.id.text1, + }; + + private static final int[] ADAPTER_TO_SHORTCUT_SUPPORTED = { + android.R.id.text1, android.R.id.text2 + }; + + private static final int[] ADAPTER_TO = IS_SHORTCUT_API_SUPPORTED ? + ADAPTER_TO_SHORTCUT_SUPPORTED : ADAPTER_TO_SHORTCUT_UNSUPPORTED; + + // Either the locale is empty (means the word is applicable to all locales) + // or the word equals our current locale + private static final String QUERY_SELECTION = + UserDictionary.Words.LOCALE + "=?"; + private static final String QUERY_SELECTION_ALL_LOCALES = + UserDictionary.Words.LOCALE + " is null"; + + private static final String DELETE_SELECTION_WITH_SHORTCUT = UserDictionary.Words.WORD + + "=? AND " + UserDictionary.Words.SHORTCUT + "=?"; + private static final String DELETE_SELECTION_WITHOUT_SHORTCUT = UserDictionary.Words.WORD + + "=? AND " + UserDictionary.Words.SHORTCUT + " is null OR " + + UserDictionary.Words.SHORTCUT + "=''"; + private static final String DELETE_SELECTION_SHORTCUT_UNSUPPORTED = + UserDictionary.Words.WORD + "=?"; + + private static final int OPTIONS_MENU_ADD = Menu.FIRST; + + private Cursor mCursor; + + protected String mLocale; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getActivity().getActionBar().setTitle(R.string.edit_personal_dictionary); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate( + R.layout.user_dictionary_preference_list_fragment, container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final Intent intent = getActivity().getIntent(); + final String localeFromIntent = + null == intent ? null : intent.getStringExtra("locale"); + + final Bundle arguments = getArguments(); + final String localeFromArguments = + null == arguments ? null : arguments.getString("locale"); + + final String locale; + if (null != localeFromArguments) { + locale = localeFromArguments; + } else if (null != localeFromIntent) { + locale = localeFromIntent; + } else { + locale = null; + } + + mLocale = locale; + // WARNING: The following cursor is never closed! TODO: don't put that in a member, and + // make sure all cursors are correctly closed. Also, this comes from a call to + // Activity#managedQuery, which has been deprecated for a long time (and which FORBIDS + // closing the cursor, so take care when resolving this TODO). We should either use a + // regular query and close the cursor, or switch to a LoaderManager and a CursorLoader. + mCursor = createCursor(locale); + TextView emptyView = (TextView) getView().findViewById(android.R.id.empty); + emptyView.setText(R.string.user_dict_settings_empty_text); + + final ListView listView = getListView(); + listView.setAdapter(createAdapter()); + listView.setFastScrollEnabled(true); + listView.setEmptyView(emptyView); + + setHasOptionsMenu(true); + // Show the language as a subtitle of the action bar + getActivity().getActionBar().setSubtitle( + UserDictionarySettingsUtils.getLocaleDisplayName(getActivity(), mLocale)); + } + + @Override + public void onResume() { + super.onResume(); + ListAdapter adapter = getListView().getAdapter(); + if (adapter != null && adapter instanceof MyAdapter) { + // The list view is forced refreshed here. This allows the changes done + // in UserDictionaryAddWordFragment (update/delete/insert) to be seen when + // user goes back to this view. + MyAdapter listAdapter = (MyAdapter) adapter; + listAdapter.notifyDataSetChanged(); + } + } + + @SuppressWarnings("deprecation") + private Cursor createCursor(final String locale) { + // Locale can be any of: + // - The string representation of a locale, as returned by Locale#toString() + // - The empty string. This means we want a cursor returning words valid for all locales. + // - null. This means we want a cursor for the current locale, whatever this is. + // Note that this contrasts with the data inside the database, where NULL means "all + // locales" and there should never be an empty string. The confusion is called by the + // historical use of null for "all locales". + // TODO: it should be easy to make this more readable by making the special values + // human-readable, like "all_locales" and "current_locales" strings, provided they + // can be guaranteed not to match locales that may exist. + if ("".equals(locale)) { + // Case-insensitive sort + return getActivity().managedQuery(UserDictionary.Words.CONTENT_URI, QUERY_PROJECTION, + QUERY_SELECTION_ALL_LOCALES, null, + "UPPER(" + UserDictionary.Words.WORD + ")"); + } + final String queryLocale = null != locale ? locale : Locale.getDefault().toString(); + return getActivity().managedQuery(UserDictionary.Words.CONTENT_URI, QUERY_PROJECTION, + QUERY_SELECTION, new String[] { queryLocale }, + "UPPER(" + UserDictionary.Words.WORD + ")"); + } + + private ListAdapter createAdapter() { + return new MyAdapter(getActivity(), R.layout.user_dictionary_item, mCursor, + ADAPTER_FROM, ADAPTER_TO); + } + + @Override + public void onListItemClick(ListView l, View v, int position, long id) { + final String word = getWord(position); + final String shortcut = getShortcut(position); + if (word != null) { + showAddOrEditDialog(word, shortcut); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (!UserDictionarySettings.IS_SHORTCUT_API_SUPPORTED) { + final Locale systemLocale = getResources().getConfiguration().locale; + if (!TextUtils.isEmpty(mLocale) && !mLocale.equals(systemLocale.toString())) { + // Hide the add button for ICS because it doesn't support specifying a locale + // for an entry. This new "locale"-aware API has been added in conjunction + // with the shortcut API. + return; + } + } + MenuItem actionItem = + menu.add(0, OPTIONS_MENU_ADD, 0, R.string.user_dict_settings_add_menu_title) + .setIcon(R.drawable.ic_menu_add); + actionItem.setShowAsAction( + MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == OPTIONS_MENU_ADD) { + showAddOrEditDialog(null, null); + return true; + } + return false; + } + + /** + * Add or edit a word. If editingWord is null, it's an add; otherwise, it's an edit. + * @param editingWord the word to edit, or null if it's an add. + * @param editingShortcut the shortcut for this entry, or null if none. + */ + private void showAddOrEditDialog(final String editingWord, final String editingShortcut) { + final Bundle args = new Bundle(); + args.putInt(UserDictionaryAddWordContents.EXTRA_MODE, null == editingWord + ? UserDictionaryAddWordContents.MODE_INSERT + : UserDictionaryAddWordContents.MODE_EDIT); + args.putString(UserDictionaryAddWordContents.EXTRA_WORD, editingWord); + args.putString(UserDictionaryAddWordContents.EXTRA_SHORTCUT, editingShortcut); + args.putString(UserDictionaryAddWordContents.EXTRA_LOCALE, mLocale); + android.preference.PreferenceActivity pa = + (android.preference.PreferenceActivity)getActivity(); + pa.startPreferencePanel(UserDictionaryAddWordFragment.class.getName(), + args, R.string.user_dict_settings_add_dialog_title, null, null, 0); + } + + private String getWord(final int position) { + if (null == mCursor) return null; + mCursor.moveToPosition(position); + // Handle a possible race-condition + if (mCursor.isAfterLast()) return null; + + return mCursor.getString( + mCursor.getColumnIndexOrThrow(UserDictionary.Words.WORD)); + } + + private String getShortcut(final int position) { + if (!IS_SHORTCUT_API_SUPPORTED) return null; + if (null == mCursor) return null; + mCursor.moveToPosition(position); + // Handle a possible race-condition + if (mCursor.isAfterLast()) return null; + + return mCursor.getString( + mCursor.getColumnIndexOrThrow(UserDictionary.Words.SHORTCUT)); + } + + public static void deleteWord(final String word, final String shortcut, + final ContentResolver resolver) { + if (!IS_SHORTCUT_API_SUPPORTED) { + resolver.delete(UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_SHORTCUT_UNSUPPORTED, + new String[] { word }); + } else if (TextUtils.isEmpty(shortcut)) { + resolver.delete( + UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITHOUT_SHORTCUT, + new String[] { word }); + } else { + resolver.delete( + UserDictionary.Words.CONTENT_URI, DELETE_SELECTION_WITH_SHORTCUT, + new String[] { word, shortcut }); + } + } + + private static class MyAdapter extends SimpleCursorAdapter implements SectionIndexer { + private AlphabetIndexer mIndexer; + + private ViewBinder mViewBinder = new ViewBinder() { + + @Override + public boolean setViewValue(final View v, final Cursor c, final int columnIndex) { + if (!IS_SHORTCUT_API_SUPPORTED) { + // just let SimpleCursorAdapter set the view values + return false; + } + if (columnIndex == INDEX_SHORTCUT) { + final String shortcut = c.getString(INDEX_SHORTCUT); + if (TextUtils.isEmpty(shortcut)) { + v.setVisibility(View.GONE); + } else { + ((TextView)v).setText(shortcut); + v.setVisibility(View.VISIBLE); + } + v.invalidate(); + return true; + } + + return false; + } + }; + + public MyAdapter(final Context context, final int layout, final Cursor c, + final String[] from, final int[] to) { + super(context, layout, c, from, to, 0 /* flags */); + + if (null != c) { + final String alphabet = context.getString(R.string.user_dict_fast_scroll_alphabet); + final int wordColIndex = c.getColumnIndexOrThrow(UserDictionary.Words.WORD); + mIndexer = new AlphabetIndexer(c, wordColIndex, alphabet); + } + setViewBinder(mViewBinder); + } + + @Override + public int getPositionForSection(final int section) { + return null == mIndexer ? 0 : mIndexer.getPositionForSection(section); + } + + @Override + public int getSectionForPosition(final int position) { + return null == mIndexer ? 0 : mIndexer.getSectionForPosition(position); + } + + @Override + public Object[] getSections() { + return null == mIndexer ? null : mIndexer.getSections(); + } + } +} + diff --git a/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettingsUtils.java b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettingsUtils.java new file mode 100644 index 000000000..095ab3e09 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettingsUtils.java @@ -0,0 +1,42 @@ +/* + * 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 org.kelar.inputmethod.latin.userdictionary; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; + +import android.content.Context; +import android.text.TextUtils; + +import java.util.Locale; + +/** + * Utilities of the user dictionary settings + * TODO: We really want to move these utilities to a static library. + */ +public class UserDictionarySettingsUtils { + public static String getLocaleDisplayName(Context context, String localeStr) { + if (TextUtils.isEmpty(localeStr)) { + // CAVEAT: localeStr should not be null because a null locale stands for the system + // locale in UserDictionary.Words.addWord. + return context.getResources().getString(R.string.user_dict_settings_all_languages); + } + final Locale locale = LocaleUtils.constructLocaleFromString(localeStr); + final Locale systemLocale = context.getResources().getConfiguration().locale; + return locale.getDisplayName(systemLocale); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/AdditionalSubtypeUtils.java b/java/src/org/kelar/inputmethod/latin/utils/AdditionalSubtypeUtils.java new file mode 100644 index 000000000..2b44fcd91 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/AdditionalSubtypeUtils.java @@ -0,0 +1,238 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import static org.kelar.inputmethod.latin.common.Constants.Subtype.KEYBOARD_MODE; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.ASCII_CAPABLE; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.EMOJI_CAPABLE; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.IS_ADDITIONAL_SUBTYPE; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME; + +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.compat.InputMethodSubtypeCompatUtils; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; + +public final class AdditionalSubtypeUtils { + private static final String TAG = AdditionalSubtypeUtils.class.getSimpleName(); + + private static final InputMethodSubtype[] EMPTY_SUBTYPE_ARRAY = new InputMethodSubtype[0]; + + private AdditionalSubtypeUtils() { + // This utility class is not publicly instantiable. + } + + @UsedForTesting + public static boolean isAdditionalSubtype(final InputMethodSubtype subtype) { + return subtype.containsExtraValueKey(IS_ADDITIONAL_SUBTYPE); + } + + private static final String LOCALE_AND_LAYOUT_SEPARATOR = ":"; + private static final int INDEX_OF_LOCALE = 0; + private static final int INDEX_OF_KEYBOARD_LAYOUT = 1; + private static final int INDEX_OF_EXTRA_VALUE = 2; + private static final int LENGTH_WITHOUT_EXTRA_VALUE = (INDEX_OF_KEYBOARD_LAYOUT + 1); + private static final int LENGTH_WITH_EXTRA_VALUE = (INDEX_OF_EXTRA_VALUE + 1); + private static final String PREF_SUBTYPE_SEPARATOR = ";"; + + private static InputMethodSubtype createAdditionalSubtypeInternal( + final String localeString, final String keyboardLayoutSetName, + final boolean isAsciiCapable, final boolean isEmojiCapable) { + final int nameId = SubtypeLocaleUtils.getSubtypeNameId(localeString, keyboardLayoutSetName); + final String platformVersionDependentExtraValues = getPlatformVersionDependentExtraValue( + localeString, keyboardLayoutSetName, isAsciiCapable, isEmojiCapable); + final int platformVersionIndependentSubtypeId = + getPlatformVersionIndependentSubtypeId(localeString, keyboardLayoutSetName); + // NOTE: In KitKat and later, InputMethodSubtypeBuilder#setIsAsciiCapable is also available. + // TODO: Use InputMethodSubtypeBuilder#setIsAsciiCapable when appropriate. + return InputMethodSubtypeCompatUtils.newInputMethodSubtype(nameId, + R.drawable.ic_ime_switcher_dark, localeString, KEYBOARD_MODE, + platformVersionDependentExtraValues, + false /* isAuxiliary */, false /* overrideImplicitlyEnabledSubtype */, + platformVersionIndependentSubtypeId); + } + + public static InputMethodSubtype createDummyAdditionalSubtype( + final String localeString, final String keyboardLayoutSetName) { + return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, + false /* isAsciiCapable */, false /* isEmojiCapable */); + } + + public static InputMethodSubtype createAsciiEmojiCapableAdditionalSubtype( + final String localeString, final String keyboardLayoutSetName) { + return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, + true /* isAsciiCapable */, true /* isEmojiCapable */); + } + + public static String getPrefSubtype(final InputMethodSubtype subtype) { + final String localeString = subtype.getLocale(); + final String keyboardLayoutSetName = SubtypeLocaleUtils.getKeyboardLayoutSetName(subtype); + final String layoutExtraValue = KEYBOARD_LAYOUT_SET + "=" + keyboardLayoutSetName; + final String extraValue = StringUtils.removeFromCommaSplittableTextIfExists( + layoutExtraValue, StringUtils.removeFromCommaSplittableTextIfExists( + IS_ADDITIONAL_SUBTYPE, subtype.getExtraValue())); + final String basePrefSubtype = localeString + LOCALE_AND_LAYOUT_SEPARATOR + + keyboardLayoutSetName; + return extraValue.isEmpty() ? basePrefSubtype + : basePrefSubtype + LOCALE_AND_LAYOUT_SEPARATOR + extraValue; + } + + public static InputMethodSubtype[] createAdditionalSubtypesArray(final String prefSubtypes) { + if (TextUtils.isEmpty(prefSubtypes)) { + return EMPTY_SUBTYPE_ARRAY; + } + final String[] prefSubtypeArray = prefSubtypes.split(PREF_SUBTYPE_SEPARATOR); + final ArrayList<InputMethodSubtype> subtypesList = new ArrayList<>(prefSubtypeArray.length); + for (final String prefSubtype : prefSubtypeArray) { + final String elems[] = prefSubtype.split(LOCALE_AND_LAYOUT_SEPARATOR); + if (elems.length != LENGTH_WITHOUT_EXTRA_VALUE + && elems.length != LENGTH_WITH_EXTRA_VALUE) { + Log.w(TAG, "Unknown additional subtype specified: " + prefSubtype + " in " + + prefSubtypes); + continue; + } + final String localeString = elems[INDEX_OF_LOCALE]; + final String keyboardLayoutSetName = elems[INDEX_OF_KEYBOARD_LAYOUT]; + // Here we assume that all the additional subtypes have AsciiCapable and EmojiCapable. + // This is actually what the setting dialog for additional subtype is doing. + final InputMethodSubtype subtype = createAsciiEmojiCapableAdditionalSubtype( + localeString, keyboardLayoutSetName); + if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT) { + // Skip unknown keyboard layout subtype. This may happen when predefined keyboard + // layout has been removed. + continue; + } + subtypesList.add(subtype); + } + return subtypesList.toArray(new InputMethodSubtype[subtypesList.size()]); + } + + public static String createPrefSubtypes(final InputMethodSubtype[] subtypes) { + if (subtypes == null || subtypes.length == 0) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + for (final InputMethodSubtype subtype : subtypes) { + if (sb.length() > 0) { + sb.append(PREF_SUBTYPE_SEPARATOR); + } + sb.append(getPrefSubtype(subtype)); + } + return sb.toString(); + } + + public static String createPrefSubtypes(final String[] prefSubtypes) { + if (prefSubtypes == null || prefSubtypes.length == 0) { + return ""; + } + final StringBuilder sb = new StringBuilder(); + for (final String prefSubtype : prefSubtypes) { + if (sb.length() > 0) { + sb.append(PREF_SUBTYPE_SEPARATOR); + } + sb.append(prefSubtype); + } + return sb.toString(); + } + + /** + * Returns the extra value that is optimized for the running OS. + * <p> + * Historically the extra value has been used as the last resort to annotate various kinds of + * attributes. Some of these attributes are valid only on some platform versions. Thus we cannot + * assume that the extra values stored in a persistent storage are always valid. We need to + * regenerate the extra value on the fly instead. + * </p> + * @param localeString the locale string (e.g., "en_US"). + * @param keyboardLayoutSetName the keyboard layout set name (e.g., "dvorak"). + * @param isAsciiCapable true when ASCII characters are supported with this layout. + * @param isEmojiCapable true when Unicode Emoji characters are supported with this layout. + * @return extra value that is optimized for the running OS. + * @see #getPlatformVersionIndependentSubtypeId(String, String) + */ + private static String getPlatformVersionDependentExtraValue(final String localeString, + final String keyboardLayoutSetName, final boolean isAsciiCapable, + final boolean isEmojiCapable) { + final ArrayList<String> extraValueItems = new ArrayList<>(); + extraValueItems.add(KEYBOARD_LAYOUT_SET + "=" + keyboardLayoutSetName); + if (isAsciiCapable) { + extraValueItems.add(ASCII_CAPABLE); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && + SubtypeLocaleUtils.isExceptionalLocale(localeString)) { + extraValueItems.add(UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME + "=" + + SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(keyboardLayoutSetName)); + } + if (isEmojiCapable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + extraValueItems.add(EMOJI_CAPABLE); + } + extraValueItems.add(IS_ADDITIONAL_SUBTYPE); + return TextUtils.join(",", extraValueItems); + } + + /** + * Returns the subtype ID that is supposed to be compatible between different version of OSes. + * <p> + * From the compatibility point of view, it is important to keep subtype id predictable and + * stable between different OSes. For this purpose, the calculation code in this method is + * carefully chosen and then fixed. Treat the following code as no more or less than a + * hash function. Each component to be hashed can be different from the corresponding value + * that is used to instantiate {@link InputMethodSubtype} actually. + * For example, you don't need to update <code>compatibilityExtraValueItems</code> in this + * method even when we need to add some new extra values for the actual instance of + * {@link InputMethodSubtype}. + * </p> + * @param localeString the locale string (e.g., "en_US"). + * @param keyboardLayoutSetName the keyboard layout set name (e.g., "dvorak"). + * @return a platform-version independent subtype ID. + * @see #getPlatformVersionDependentExtraValue(String, String, boolean, boolean) + */ + private static int getPlatformVersionIndependentSubtypeId(final String localeString, + final String keyboardLayoutSetName) { + // For compatibility reasons, we concatenate the extra values in the following order. + // - KeyboardLayoutSet + // - AsciiCapable + // - UntranslatableReplacementStringInSubtypeName + // - EmojiCapable + // - isAdditionalSubtype + final ArrayList<String> compatibilityExtraValueItems = new ArrayList<>(); + compatibilityExtraValueItems.add(KEYBOARD_LAYOUT_SET + "=" + keyboardLayoutSetName); + compatibilityExtraValueItems.add(ASCII_CAPABLE); + if (SubtypeLocaleUtils.isExceptionalLocale(localeString)) { + compatibilityExtraValueItems.add(UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME + "=" + + SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(keyboardLayoutSetName)); + } + compatibilityExtraValueItems.add(EMOJI_CAPABLE); + compatibilityExtraValueItems.add(IS_ADDITIONAL_SUBTYPE); + final String compatibilityExtraValues = TextUtils.join(",", compatibilityExtraValueItems); + return Arrays.hashCode(new Object[] { + localeString, + KEYBOARD_MODE, + compatibilityExtraValues, + false /* isAuxiliary */, + false /* overrideImplicitlyEnabledSubtype */ }); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ApplicationUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ApplicationUtils.java new file mode 100644 index 000000000..13d5f2a00 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ApplicationUtils.java @@ -0,0 +1,83 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager.NameNotFoundException; +import android.util.Log; + +public final class ApplicationUtils { + private static final String TAG = ApplicationUtils.class.getSimpleName(); + + private ApplicationUtils() { + // This utility class is not publicly instantiable. + } + + public static int getActivityTitleResId(final Context context, + final Class<? extends Activity> cls) { + final ComponentName cn = new ComponentName(context, cls); + try { + final ActivityInfo ai = context.getPackageManager().getActivityInfo(cn, 0); + if (ai != null) { + return ai.labelRes; + } + } catch (final NameNotFoundException e) { + Log.e(TAG, "Failed to get settings activity title res id.", e); + } + return 0; + } + + /** + * A utility method to get the application's PackageInfo.versionName + * @return the application's PackageInfo.versionName + */ + public static String getVersionName(final Context context) { + try { + if (context == null) { + return ""; + } + final String packageName = context.getPackageName(); + final PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + return info.versionName; + } catch (final NameNotFoundException e) { + Log.e(TAG, "Could not find version info.", e); + } + return ""; + } + + /** + * A utility method to get the application's PackageInfo.versionCode + * @return the application's PackageInfo.versionCode + */ + public static int getVersionCode(final Context context) { + try { + if (context == null) { + return 0; + } + final String packageName = context.getPackageName(); + final PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + return info.versionCode; + } catch (final NameNotFoundException e) { + Log.e(TAG, "Could not find version info.", e); + } + return 0; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/AsyncResultHolder.java b/java/src/org/kelar/inputmethod/latin/utils/AsyncResultHolder.java new file mode 100644 index 000000000..b269f7f88 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/AsyncResultHolder.java @@ -0,0 +1,72 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * This class is a holder of the result of an asynchronous computation. + * + * @param <E> the type of the result. + */ +public class AsyncResultHolder<E> { + + private final Object mLock = new Object(); + + private E mResult; + private final String mTag; + private final CountDownLatch mLatch; + + public AsyncResultHolder(final String tag) { + mTag = tag; + mLatch = new CountDownLatch(1); + } + + /** + * Sets the result value of this holder. + * + * @param result the value to set. + */ + public void set(final E result) { + synchronized(mLock) { + if (mLatch.getCount() > 0) { + mResult = result; + mLatch.countDown(); + } + } + } + + /** + * Gets the result value held in this holder. + * Causes the current thread to wait unless the value is set or the specified time is elapsed. + * + * @param defaultValue the default value. + * @param timeOut the maximum time to wait. + * @return if the result is set before the time limit then the result, otherwise defaultValue. + */ + public E get(final E defaultValue, final long timeOut) { + try { + return mLatch.await(timeOut, TimeUnit.MILLISECONDS) ? mResult : defaultValue; + } catch (InterruptedException e) { + Log.w(mTag, "get() : Interrupted after " + timeOut + " ms"); + return defaultValue; + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/AutoCorrectionUtils.java b/java/src/org/kelar/inputmethod/latin/utils/AutoCorrectionUtils.java new file mode 100644 index 000000000..7410abddf --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/AutoCorrectionUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.define.DebugFlags; + +public final class AutoCorrectionUtils { + private static final boolean DBG = DebugFlags.DEBUG_ENABLED; + private static final String TAG = AutoCorrectionUtils.class.getSimpleName(); + + private AutoCorrectionUtils() { + // Purely static class: can't instantiate. + } + + public static boolean suggestionExceedsThreshold(final SuggestedWordInfo suggestion, + final String consideredWord, final float threshold) { + if (null != suggestion) { + // Shortlist a whitelisted word + if (suggestion.isKindOf(SuggestedWordInfo.KIND_WHITELIST)) { + return true; + } + // TODO: return suggestion.isAprapreateForAutoCorrection(); + if (!suggestion.isAprapreateForAutoCorrection()) { + return false; + } + final int autoCorrectionSuggestionScore = suggestion.mScore; + // TODO: when the normalized score of the first suggestion is nearly equals to + // the normalized score of the second suggestion, behave less aggressive. + final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore( + consideredWord, suggestion.mWord, autoCorrectionSuggestionScore); + if (DBG) { + Log.d(TAG, "Normalized " + consideredWord + "," + suggestion + "," + + autoCorrectionSuggestionScore + ", " + normalizedScore + + "(" + threshold + ")"); + } + if (normalizedScore >= threshold) { + if (DBG) { + Log.d(TAG, "Exceeds threshold."); + } + return true; + } + } + return false; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/BinaryDictionaryUtils.java b/java/src/org/kelar/inputmethod/latin/utils/BinaryDictionaryUtils.java new file mode 100644 index 000000000..4020ca62a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/BinaryDictionaryUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.BinaryDictionary; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.UnsupportedFormatException; + +import java.io.File; +import java.io.IOException; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class BinaryDictionaryUtils { + private static final String TAG = BinaryDictionaryUtils.class.getSimpleName(); + + private BinaryDictionaryUtils() { + // This utility class is not publicly instantiable. + } + + static { + JniUtils.loadNativeLibrary(); + } + + @UsedForTesting + private static native boolean createEmptyDictFileNative(String filePath, long dictVersion, + String locale, String[] attributeKeyStringArray, String[] attributeValueStringArray); + private static native float calcNormalizedScoreNative(int[] before, int[] after, int score); + private static native int setCurrentTimeForTestNative(int currentTime); + + public static DictionaryHeader getHeader(final File dictFile) + throws IOException, UnsupportedFormatException { + return getHeaderWithOffsetAndLength(dictFile, 0 /* offset */, dictFile.length()); + } + + public static DictionaryHeader getHeaderWithOffsetAndLength(final File dictFile, + final long offset, final long length) throws IOException, UnsupportedFormatException { + // dictType is never used for reading the header. Passing an empty string. + final BinaryDictionary binaryDictionary = new BinaryDictionary( + dictFile.getAbsolutePath(), offset, length, + true /* useFullEditDistance */, null /* locale */, "" /* dictType */, + false /* isUpdatable */); + final DictionaryHeader header = binaryDictionary.getHeader(); + binaryDictionary.close(); + if (header == null) { + throw new IOException(); + } + return header; + } + + public static boolean renameDict(final File dictFile, final File newDictFile) { + if (dictFile.isFile()) { + return dictFile.renameTo(newDictFile); + } else if (dictFile.isDirectory()) { + final String dictName = dictFile.getName(); + final String newDictName = newDictFile.getName(); + if (newDictFile.exists()) { + return false; + } + for (final File file : dictFile.listFiles()) { + if (!file.isFile()) { + continue; + } + final String fileName = file.getName(); + final String newFileName = fileName.replaceFirst( + Pattern.quote(dictName), Matcher.quoteReplacement(newDictName)); + if (!file.renameTo(new File(dictFile, newFileName))) { + return false; + } + } + return dictFile.renameTo(newDictFile); + } + return false; + } + + @UsedForTesting + public static boolean createEmptyDictFile(final String filePath, final long dictVersion, + final Locale locale, final Map<String, String> attributeMap) { + final String[] keyArray = new String[attributeMap.size()]; + final String[] valueArray = new String[attributeMap.size()]; + int index = 0; + for (final String key : attributeMap.keySet()) { + keyArray[index] = key; + valueArray[index] = attributeMap.get(key); + index++; + } + return createEmptyDictFileNative(filePath, dictVersion, locale.toString(), keyArray, + valueArray); + } + + public static float calcNormalizedScore(final String before, final String after, + final int score) { + return calcNormalizedScoreNative(StringUtils.toCodePointArray(before), + StringUtils.toCodePointArray(after), score); + } + + /** + * Control the current time to be used in the native code. If currentTime >= 0, this method sets + * the current time and gets into test mode. + * In test mode, set timestamp is used as the current time in the native code. + * If currentTime < 0, quit the test mode and returns to using time() to get the current time. + * + * @param currentTime seconds since the unix epoch + * @return current time got in the native code. + */ + @UsedForTesting + public static int setCurrentTimeForTest(final int currentTime) { + return setCurrentTimeForTestNative(currentTime); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/CapsModeUtils.java b/java/src/org/kelar/inputmethod/latin/utils/CapsModeUtils.java new file mode 100644 index 000000000..ee42b4f59 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/CapsModeUtils.java @@ -0,0 +1,357 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.text.InputType; +import android.text.TextUtils; + +import org.kelar.inputmethod.latin.WordComposer; +import org.kelar.inputmethod.latin.common.Constants; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; + +import java.util.ArrayList; +import java.util.Locale; + +public final class CapsModeUtils { + private CapsModeUtils() { + // This utility class is not publicly instantiable. + } + + /** + * Apply an auto-caps mode to a string. + * + * This intentionally does NOT apply manual caps mode. It only changes the capitalization if + * the mode is one of the auto-caps modes. + * @param s The string to capitalize. + * @param capitalizeMode The mode in which to capitalize. + * @param locale The locale for capitalizing. + * @return The capitalized string. + */ + public static String applyAutoCapsMode(final String s, final int capitalizeMode, + final Locale locale) { + if (WordComposer.CAPS_MODE_AUTO_SHIFT_LOCKED == capitalizeMode) { + return s.toUpperCase(locale); + } else if (WordComposer.CAPS_MODE_AUTO_SHIFTED == capitalizeMode) { + return StringUtils.capitalizeFirstCodePoint(s, locale); + } else { + return s; + } + } + + /** + * Return whether a constant represents an auto-caps mode (either auto-shift or auto-shift-lock) + * @param mode The mode to test for + * @return true if this represents an auto-caps mode, false otherwise + */ + public static boolean isAutoCapsMode(final int mode) { + return WordComposer.CAPS_MODE_AUTO_SHIFTED == mode + || WordComposer.CAPS_MODE_AUTO_SHIFT_LOCKED == mode; + } + + /** + * Helper method to find out if a code point is starting punctuation. + * + * This include the Unicode START_PUNCTUATION category, but also some other symbols that are + * starting, like the inverted question mark or the double quote. + * + * @param codePoint the code point + * @return true if it's starting punctuation, false otherwise. + */ + private static boolean isStartPunctuation(final int codePoint) { + return (codePoint == Constants.CODE_DOUBLE_QUOTE || codePoint == Constants.CODE_SINGLE_QUOTE + || codePoint == Constants.CODE_INVERTED_QUESTION_MARK + || codePoint == Constants.CODE_INVERTED_EXCLAMATION_MARK + || Character.getType(codePoint) == Character.START_PUNCTUATION); + } + + /** + * Determine what caps mode should be in effect at the current offset in + * the text. Only the mode bits set in <var>reqModes</var> will be + * checked. Note that the caps mode flags here are explicitly defined + * to match those in {@link InputType}. + * + * This code is a straight copy of TextUtils.getCapsMode (modulo namespace and formatting + * issues). This will change in the future as we simplify the code for our use and fix bugs. + * + * @param cs The text that should be checked for caps modes. + * @param reqModes The modes to be checked: may be any combination of + * {@link TextUtils#CAP_MODE_CHARACTERS}, {@link TextUtils#CAP_MODE_WORDS}, and + * {@link TextUtils#CAP_MODE_SENTENCES}. + * @param spacingAndPunctuations The current spacing and punctuations settings. + * @param hasSpaceBefore Whether we should consider there is a space inserted at the end of cs + * + * @return Returns the actual capitalization modes that can be in effect + * at the current position, which is any combination of + * {@link TextUtils#CAP_MODE_CHARACTERS}, {@link TextUtils#CAP_MODE_WORDS}, and + * {@link TextUtils#CAP_MODE_SENTENCES}. + */ + public static int getCapsMode(final CharSequence cs, final int reqModes, + final SpacingAndPunctuations spacingAndPunctuations, final boolean hasSpaceBefore) { + // Quick description of what we want to do: + // CAP_MODE_CHARACTERS is always on. + // CAP_MODE_WORDS is on if there is some whitespace before the cursor. + // CAP_MODE_SENTENCES is on if there is some whitespace before the cursor, and the end + // of a sentence just before that. + // We ignore opening parentheses and the like just before the cursor for purposes of + // finding whitespace for WORDS and SENTENCES modes. + // The end of a sentence ends with a period, question mark or exclamation mark. If it's + // a period, it also needs not to be an abbreviation, which means it also needs to either + // be immediately preceded by punctuation, or by a string of only letters with single + // periods interleaved. + + // Step 1 : check for cap MODE_CHARACTERS. If it's looked for, it's always on. + if ((reqModes & (TextUtils.CAP_MODE_WORDS | TextUtils.CAP_MODE_SENTENCES)) == 0) { + // Here we are not looking for MODE_WORDS or MODE_SENTENCES, so since we already + // evaluated MODE_CHARACTERS, we can return. + return TextUtils.CAP_MODE_CHARACTERS & reqModes; + } + + // Step 2 : Skip (ignore at the end of input) any opening punctuation. This includes + // opening parentheses, brackets, opening quotes, everything that *opens* a span of + // text in the linguistic sense. In RTL languages, this is still an opening sign, although + // it may look like a right parenthesis for example. We also include double quote and + // single quote since they aren't start punctuation in the unicode sense, but should still + // be skipped for English. TODO: does this depend on the language? + int i; + if (hasSpaceBefore) { + i = cs.length() + 1; + } else { + for (i = cs.length(); i > 0; i--) { + final char c = cs.charAt(i - 1); + if (!isStartPunctuation(c)) { + break; + } + } + } + + // We are now on the character that precedes any starting punctuation, so in the most + // frequent case this will be whitespace or a letter, although it may occasionally be a + // start of line, or some symbol. + + // Step 3 : Search for the start of a paragraph. From the starting point computed in step 2, + // we go back over any space or tab char sitting there. We find the start of a paragraph + // if the first char that's not a space or tab is a start of line (as in \n, start of text, + // or some other similar characters). + int j = i; + char prevChar = Constants.CODE_SPACE; + if (hasSpaceBefore) --j; + while (j > 0) { + prevChar = cs.charAt(j - 1); + if (!Character.isSpaceChar(prevChar) && prevChar != Constants.CODE_TAB) break; + j--; + } + if (j <= 0 || Character.isWhitespace(prevChar)) { + if (spacingAndPunctuations.mUsesGermanRules) { + // In German typography rules, there is a specific case that the first character + // of a new line should not be capitalized if the previous line ends in a comma. + boolean hasNewLine = false; + while (--j >= 0 && Character.isWhitespace(prevChar)) { + if (Constants.CODE_ENTER == prevChar) { + hasNewLine = true; + } + prevChar = cs.charAt(j); + } + if (Constants.CODE_COMMA == prevChar && hasNewLine) { + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS) & reqModes; + } + } + // There are only spacing chars between the start of the paragraph and the cursor, + // defined as a isWhitespace() char that is neither a isSpaceChar() nor a tab. Both + // MODE_WORDS and MODE_SENTENCES should be active. + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS + | TextUtils.CAP_MODE_SENTENCES) & reqModes; + } + if (i == j) { + // If we don't have whitespace before index i, it means neither MODE_WORDS + // nor mode sentences should be on so we can return right away. + return TextUtils.CAP_MODE_CHARACTERS & reqModes; + } + if ((reqModes & TextUtils.CAP_MODE_SENTENCES) == 0) { + // Here we know we have whitespace before the cursor (if not, we returned in the above + // if i == j clause), so we need MODE_WORDS to be on. And we don't need to evaluate + // MODE_SENTENCES so we can return right away. + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS) & reqModes; + } + // Please note that because of the reqModes & CAP_MODE_SENTENCES test a few lines above, + // we know that MODE_SENTENCES is being requested. + + // Step 4 : Search for MODE_SENTENCES. + // English is a special case in that "American typography" rules, which are the most common + // in English, state that a sentence terminator immediately following a quotation mark + // should be swapped with it and de-duplicated (included in the quotation mark), + // e.g. <<Did they say, "let's go home?">> + // No other language has such a rule as far as I know, instead putting inside the quotation + // mark as the exact thing quoted and handling the surrounding punctuation independently, + // e.g. <<Did they say, "let's go home"?>> + if (spacingAndPunctuations.mUsesAmericanTypography) { + for (; j > 0; j--) { + // Here we look to go over any closing punctuation. This is because in dominant + // variants of English, the final period is placed within double quotes and maybe + // other closing punctuation signs. This is generally not true in other languages. + final char c = cs.charAt(j - 1); + if (c != Constants.CODE_DOUBLE_QUOTE && c != Constants.CODE_SINGLE_QUOTE + && Character.getType(c) != Character.END_PUNCTUATION) { + break; + } + } + } + + if (j <= 0) return TextUtils.CAP_MODE_CHARACTERS & reqModes; + char c = cs.charAt(--j); + + // We found the next interesting chunk of text ; next we need to determine if it's the + // end of a sentence. If we have a sentence terminator (typically a question mark or an + // exclamation mark), then it's the end of a sentence; however, we treat the abbreviation + // marker specially because usually is the same char as the sentence separator (the + // period in most languages) and in this case we need to apply a heuristic to determine + // in which of these senses it's used. + if (spacingAndPunctuations.isSentenceTerminator(c) + && !spacingAndPunctuations.isAbbreviationMarker(c)) { + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS + | TextUtils.CAP_MODE_SENTENCES) & reqModes; + } + // If we reach here, we know we have whitespace before the cursor and before that there + // is something that either does not terminate the sentence, or a symbol preceded by the + // start of the text, or it's the sentence separator AND it happens to be the same code + // point as the abbreviation marker. + // If it's a symbol or something that does not terminate the sentence, then we need to + // return caps for MODE_CHARACTERS and MODE_WORDS, but not for MODE_SENTENCES. + if (!spacingAndPunctuations.isSentenceSeparator(c) || j <= 0) { + return (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS) & reqModes; + } + + // We found out that we have a period. We need to determine if this is a full stop or + // otherwise sentence-ending period, or an abbreviation like "e.g.". An abbreviation + // looks like (\w\.){2,}. Moreover, in German, you put periods after digits for dates + // and some other things, and in German specifically we need to not go into autocaps after + // a whitespace-digits-period sequence. + // To find out, we will have a simple state machine with the following states : + // START, WORD, PERIOD, ABBREVIATION, NUMBER + // On START : (just before the first period) + // letter => WORD + // digit => NUMBER if German; end with caps otherwise + // whitespace => end with no caps (it was a stand-alone period) + // otherwise => end with caps (several periods/symbols in a row) + // On WORD : (within the word just before the first period) + // letter => WORD + // period => PERIOD + // otherwise => end with caps (it was a word with a full stop at the end) + // On PERIOD : (period within a potential abbreviation) + // letter => LETTER + // otherwise => end with caps (it was not an abbreviation) + // On LETTER : (letter within a potential abbreviation) + // letter => LETTER + // period => PERIOD + // otherwise => end with no caps (it was an abbreviation) + // On NUMBER : (period immediately preceded by one or more digits) + // digit => NUMBER + // letter => LETTER (promote to word) + // otherwise => end with no caps (it was a whitespace-digits-period sequence, + // or a punctuation-digits-period sequence like "11.11.") + // "Not an abbreviation" in the above chart essentially covers cases like "...yes.". This + // should capitalize. + + final int START = 0; + final int WORD = 1; + final int PERIOD = 2; + final int LETTER = 3; + final int NUMBER = 4; + final int caps = (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS + | TextUtils.CAP_MODE_SENTENCES) & reqModes; + final int noCaps = (TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS) & reqModes; + int state = START; + while (j > 0) { + c = cs.charAt(--j); + switch (state) { + case START: + if (Character.isLetter(c)) { + state = WORD; + } else if (Character.isWhitespace(c)) { + return noCaps; + } else if (Character.isDigit(c) && spacingAndPunctuations.mUsesGermanRules) { + state = NUMBER; + } else { + return caps; + } + break; + case WORD: + if (Character.isLetter(c)) { + state = WORD; + } else if (spacingAndPunctuations.isSentenceSeparator(c)) { + state = PERIOD; + } else { + return caps; + } + break; + case PERIOD: + if (Character.isLetter(c)) { + state = LETTER; + } else { + return caps; + } + break; + case LETTER: + if (Character.isLetter(c)) { + state = LETTER; + } else if (spacingAndPunctuations.isSentenceSeparator(c)) { + state = PERIOD; + } else { + return noCaps; + } + break; + case NUMBER: + if (Character.isLetter(c)) { + state = WORD; + } else if (Character.isDigit(c)) { + state = NUMBER; + } else { + return noCaps; + } + } + } + // Here we arrived at the start of the line. This should behave exactly like whitespace. + return (START == state || LETTER == state) ? noCaps : caps; + } + + /** + * Convert capitalize mode flags into human readable text. + * + * @param capsFlags The modes flags to be converted. It may be any combination of + * {@link TextUtils#CAP_MODE_CHARACTERS}, {@link TextUtils#CAP_MODE_WORDS}, and + * {@link TextUtils#CAP_MODE_SENTENCES}. + * @return the text that describe the <code>capsMode</code>. + */ + public static String flagsToString(final int capsFlags) { + final int capsFlagsMask = TextUtils.CAP_MODE_CHARACTERS | TextUtils.CAP_MODE_WORDS + | TextUtils.CAP_MODE_SENTENCES; + if ((capsFlags & ~capsFlagsMask) != 0) { + return "unknown<0x" + Integer.toHexString(capsFlags) + ">"; + } + final ArrayList<String> builder = new ArrayList<>(); + if ((capsFlags & android.text.TextUtils.CAP_MODE_CHARACTERS) != 0) { + builder.add("characters"); + } + if ((capsFlags & android.text.TextUtils.CAP_MODE_WORDS) != 0) { + builder.add("words"); + } + if ((capsFlags & android.text.TextUtils.CAP_MODE_SENTENCES) != 0) { + builder.add("sentences"); + } + return builder.isEmpty() ? "none" : TextUtils.join("|", builder); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/CombinedFormatUtils.java b/java/src/org/kelar/inputmethod/latin/utils/CombinedFormatUtils.java new file mode 100644 index 000000000..62ecc8d04 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/CombinedFormatUtils.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.NgramProperty; +import org.kelar.inputmethod.latin.makedict.ProbabilityInfo; +import org.kelar.inputmethod.latin.makedict.WordProperty; + +import java.util.HashMap; + +public class CombinedFormatUtils { + public static final String DICTIONARY_TAG = "dictionary"; + public static final String BIGRAM_TAG = "bigram"; + public static final String NGRAM_TAG = "ngram"; + public static final String NGRAM_PREV_WORD_TAG = "prev_word"; + public static final String PROBABILITY_TAG = "f"; + public static final String HISTORICAL_INFO_TAG = "historicalInfo"; + public static final String HISTORICAL_INFO_SEPARATOR = ":"; + public static final String WORD_TAG = "word"; + public static final String BEGINNING_OF_SENTENCE_TAG = "beginning_of_sentence"; + public static final String NOT_A_WORD_TAG = "not_a_word"; + public static final String POSSIBLY_OFFENSIVE_TAG = "possibly_offensive"; + public static final String TRUE_VALUE = "true"; + + public static String formatAttributeMap(final HashMap<String, String> attributeMap) { + final StringBuilder builder = new StringBuilder(); + builder.append(DICTIONARY_TAG + "="); + if (attributeMap.containsKey(DictionaryHeader.DICTIONARY_ID_KEY)) { + builder.append(attributeMap.get(DictionaryHeader.DICTIONARY_ID_KEY)); + } + for (final String key : attributeMap.keySet()) { + if (key.equals(DictionaryHeader.DICTIONARY_ID_KEY)) { + continue; + } + final String value = attributeMap.get(key); + builder.append("," + key + "=" + value); + } + builder.append("\n"); + return builder.toString(); + } + + public static String formatWordProperty(final WordProperty wordProperty) { + final StringBuilder builder = new StringBuilder(); + builder.append(" " + WORD_TAG + "=" + wordProperty.mWord); + builder.append(","); + builder.append(formatProbabilityInfo(wordProperty.mProbabilityInfo)); + if (wordProperty.mIsBeginningOfSentence) { + builder.append("," + BEGINNING_OF_SENTENCE_TAG + "=" + TRUE_VALUE); + } + if (wordProperty.mIsNotAWord) { + builder.append("," + NOT_A_WORD_TAG + "=" + TRUE_VALUE); + } + if (wordProperty.mIsPossiblyOffensive) { + builder.append("," + POSSIBLY_OFFENSIVE_TAG + "=" + TRUE_VALUE); + } + builder.append("\n"); + if (wordProperty.mHasNgrams) { + for (final NgramProperty ngramProperty : wordProperty.mNgrams) { + builder.append(" " + NGRAM_TAG + "=" + ngramProperty.mTargetWord.mWord); + builder.append(","); + builder.append(formatProbabilityInfo(ngramProperty.mTargetWord.mProbabilityInfo)); + builder.append("\n"); + for (int i = 0; i < ngramProperty.mNgramContext.getPrevWordCount(); i++) { + builder.append(" " + NGRAM_PREV_WORD_TAG + "[" + i + "]=" + + ngramProperty.mNgramContext.getNthPrevWord(i + 1)); + if (ngramProperty.mNgramContext.isNthPrevWordBeginningOfSentence(i + 1)) { + builder.append("," + BEGINNING_OF_SENTENCE_TAG + "=true"); + } + builder.append("\n"); + } + } + } + return builder.toString(); + } + + public static String formatProbabilityInfo(final ProbabilityInfo probabilityInfo) { + final StringBuilder builder = new StringBuilder(); + builder.append(PROBABILITY_TAG + "=" + probabilityInfo.mProbability); + if (probabilityInfo.hasHistoricalInfo()) { + builder.append(","); + builder.append(HISTORICAL_INFO_TAG + "="); + builder.append(probabilityInfo.mTimestamp); + builder.append(HISTORICAL_INFO_SEPARATOR); + builder.append(probabilityInfo.mLevel); + builder.append(HISTORICAL_INFO_SEPARATOR); + builder.append(probabilityInfo.mCount); + } + return builder.toString(); + } + + public static boolean isLiteralTrue(final String value) { + return TRUE_VALUE.equalsIgnoreCase(value); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/CompletionInfoUtils.java b/java/src/org/kelar/inputmethod/latin/utils/CompletionInfoUtils.java new file mode 100644 index 000000000..fde9594fd --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/CompletionInfoUtils.java @@ -0,0 +1,43 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.text.TextUtils; +import android.view.inputmethod.CompletionInfo; + +import java.util.Arrays; + +/** + * Utilities to do various stuff with CompletionInfo. + */ +public class CompletionInfoUtils { + private CompletionInfoUtils() { + // This utility class is not publicly instantiable. + } + + public static CompletionInfo[] removeNulls(final CompletionInfo[] src) { + int j = 0; + final CompletionInfo[] dst = new CompletionInfo[src.length]; + for (int i = 0; i < src.length; ++i) { + if (null != src[i] && !TextUtils.isEmpty(src[i].getText())) { + dst[j] = src[i]; + ++j; + } + } + return Arrays.copyOfRange(dst, 0, j); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/CursorAnchorInfoUtils.java b/java/src/org/kelar/inputmethod/latin/utils/CursorAnchorInfoUtils.java new file mode 100644 index 000000000..e79c8f376 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/CursorAnchorInfoUtils.java @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.annotation.TargetApi; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.inputmethodservice.ExtractEditText; +import android.inputmethodservice.InputMethodService; +import android.os.Build; +import android.text.Layout; +import android.text.Spannable; +import android.text.Spanned; +import android.view.View; +import android.view.ViewParent; +import android.view.inputmethod.CursorAnchorInfo; +import android.widget.TextView; + +import org.kelar.inputmethod.compat.BuildCompatUtils; +import org.kelar.inputmethod.compat.CursorAnchorInfoCompatWrapper; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * This class allows input methods to extract {@link CursorAnchorInfo} directly from the given + * {@link TextView}. This is useful and even necessary to support full-screen mode where the default + * {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)} event callback must be + * ignored because it reports the character locations of the target application rather than + * characters on {@link ExtractEditText}. + */ +public final class CursorAnchorInfoUtils { + private CursorAnchorInfoUtils() { + // This helper class is not instantiable. + } + + private static boolean isPositionVisible(final View view, final float positionX, + final float positionY) { + final float[] position = new float[] { positionX, positionY }; + View currentView = view; + + while (currentView != null) { + if (currentView != view) { + // Local scroll is already taken into account in positionX/Y + position[0] -= currentView.getScrollX(); + position[1] -= currentView.getScrollY(); + } + + if (position[0] < 0 || position[1] < 0 || + position[0] > currentView.getWidth() || position[1] > currentView.getHeight()) { + return false; + } + + if (!currentView.getMatrix().isIdentity()) { + currentView.getMatrix().mapPoints(position); + } + + position[0] += currentView.getLeft(); + position[1] += currentView.getTop(); + + final ViewParent parent = currentView.getParent(); + if (parent instanceof View) { + currentView = (View) parent; + } else { + // We've reached the ViewRoot, stop iterating + currentView = null; + } + } + + // We've been able to walk up the view hierarchy and the position was never clipped + return true; + } + + /** + * Extracts {@link CursorAnchorInfoCompatWrapper} from the given {@link TextView}. + * @param textView the target text view from which {@link CursorAnchorInfoCompatWrapper} is to + * be extracted. + * @return the {@link CursorAnchorInfoCompatWrapper} object based on the current layout. + * {@code null} if {@code Build.VERSION.SDK_INT} is 20 or prior or {@link TextView} is not + * ready to provide layout information. + */ + @Nullable + public static CursorAnchorInfoCompatWrapper extractFromTextView( + @Nonnull final TextView textView) { + if (BuildCompatUtils.EFFECTIVE_SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return null; + } + return CursorAnchorInfoCompatWrapper.wrap(extractFromTextViewInternal(textView)); + } + + /** + * Returns {@link CursorAnchorInfo} from the given {@link TextView}. + * @param textView the target text view from which {@link CursorAnchorInfo} is to be extracted. + * @return the {@link CursorAnchorInfo} object based on the current layout. {@code null} if it + * is not feasible. + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Nullable + private static CursorAnchorInfo extractFromTextViewInternal(@Nonnull final TextView textView) { + final Layout layout = textView.getLayout(); + if (layout == null) { + return null; + } + + final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); + + final int selectionStart = textView.getSelectionStart(); + builder.setSelectionRange(selectionStart, textView.getSelectionEnd()); + + // Construct transformation matrix from view local coordinates to screen coordinates. + final Matrix viewToScreenMatrix = new Matrix(textView.getMatrix()); + final int[] viewOriginInScreen = new int[2]; + textView.getLocationOnScreen(viewOriginInScreen); + viewToScreenMatrix.postTranslate(viewOriginInScreen[0], viewOriginInScreen[1]); + builder.setMatrix(viewToScreenMatrix); + + if (layout.getLineCount() == 0) { + return null; + } + final Rect lineBoundsWithoutOffset = new Rect(); + final Rect lineBoundsWithOffset = new Rect(); + layout.getLineBounds(0, lineBoundsWithoutOffset); + textView.getLineBounds(0, lineBoundsWithOffset); + final float viewportToContentHorizontalOffset = lineBoundsWithOffset.left + - lineBoundsWithoutOffset.left - textView.getScrollX(); + final float viewportToContentVerticalOffset = lineBoundsWithOffset.top + - lineBoundsWithoutOffset.top - textView.getScrollY(); + + final CharSequence text = textView.getText(); + if (text instanceof Spannable) { + // Here we assume that the composing text is marked as SPAN_COMPOSING flag. This is not + // necessarily true, but basically works. + int composingTextStart = text.length(); + int composingTextEnd = 0; + final Spannable spannable = (Spannable) text; + final Object[] spans = spannable.getSpans(0, text.length(), Object.class); + for (Object span : spans) { + final int spanFlag = spannable.getSpanFlags(span); + if ((spanFlag & Spanned.SPAN_COMPOSING) != 0) { + composingTextStart = Math.min(composingTextStart, + spannable.getSpanStart(span)); + composingTextEnd = Math.max(composingTextEnd, spannable.getSpanEnd(span)); + } + } + + final boolean hasComposingText = + (0 <= composingTextStart) && (composingTextStart < composingTextEnd); + if (hasComposingText) { + final CharSequence composingText = text.subSequence(composingTextStart, + composingTextEnd); + builder.setComposingText(composingTextStart, composingText); + + final int minLine = layout.getLineForOffset(composingTextStart); + final int maxLine = layout.getLineForOffset(composingTextEnd - 1); + for (int line = minLine; line <= maxLine; ++line) { + final int lineStart = layout.getLineStart(line); + final int lineEnd = layout.getLineEnd(line); + final int offsetStart = Math.max(lineStart, composingTextStart); + final int offsetEnd = Math.min(lineEnd, composingTextEnd); + final boolean ltrLine = + layout.getParagraphDirection(line) == Layout.DIR_LEFT_TO_RIGHT; + final float[] widths = new float[offsetEnd - offsetStart]; + layout.getPaint().getTextWidths(text, offsetStart, offsetEnd, widths); + final float top = layout.getLineTop(line); + final float bottom = layout.getLineBottom(line); + for (int offset = offsetStart; offset < offsetEnd; ++offset) { + final float charWidth = widths[offset - offsetStart]; + final boolean isRtl = layout.isRtlCharAt(offset); + final float primary = layout.getPrimaryHorizontal(offset); + final float secondary = layout.getSecondaryHorizontal(offset); + // TODO: This doesn't work perfectly for text with custom styles and TAB + // chars. + final float left; + final float right; + if (ltrLine) { + if (isRtl) { + left = secondary - charWidth; + right = secondary; + } else { + left = primary; + right = primary + charWidth; + } + } else { + if (!isRtl) { + left = secondary; + right = secondary + charWidth; + } else { + left = primary - charWidth; + right = primary; + } + } + // TODO: Check top-right and bottom-left as well. + final float localLeft = left + viewportToContentHorizontalOffset; + final float localRight = right + viewportToContentHorizontalOffset; + final float localTop = top + viewportToContentVerticalOffset; + final float localBottom = bottom + viewportToContentVerticalOffset; + final boolean isTopLeftVisible = isPositionVisible(textView, + localLeft, localTop); + final boolean isBottomRightVisible = + isPositionVisible(textView, localRight, localBottom); + int characterBoundsFlags = 0; + if (isTopLeftVisible || isBottomRightVisible) { + characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION; + } + if (!isTopLeftVisible || !isBottomRightVisible) { + characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION; + } + if (isRtl) { + characterBoundsFlags |= CursorAnchorInfo.FLAG_IS_RTL; + } + // Here offset is the index in Java chars. + builder.addCharacterBounds(offset, localLeft, localTop, localRight, + localBottom, characterBoundsFlags); + } + } + } + } + + // Treat selectionStart as the insertion point. + if (0 <= selectionStart) { + final int offset = selectionStart; + final int line = layout.getLineForOffset(offset); + final float insertionMarkerX = layout.getPrimaryHorizontal(offset) + + viewportToContentHorizontalOffset; + final float insertionMarkerTop = layout.getLineTop(line) + + viewportToContentVerticalOffset; + final float insertionMarkerBaseline = layout.getLineBaseline(line) + + viewportToContentVerticalOffset; + final float insertionMarkerBottom = layout.getLineBottom(line) + + viewportToContentVerticalOffset; + final boolean isTopVisible = + isPositionVisible(textView, insertionMarkerX, insertionMarkerTop); + final boolean isBottomVisible = + isPositionVisible(textView, insertionMarkerX, insertionMarkerBottom); + int insertionMarkerFlags = 0; + if (isTopVisible || isBottomVisible) { + insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION; + } + if (!isTopVisible || !isBottomVisible) { + insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION; + } + if (layout.isRtlCharAt(offset)) { + insertionMarkerFlags |= CursorAnchorInfo.FLAG_IS_RTL; + } + builder.setInsertionMarkerLocation(insertionMarkerX, insertionMarkerTop, + insertionMarkerBaseline, insertionMarkerBottom, insertionMarkerFlags); + } + return builder.build(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/DebugLogUtils.java b/java/src/org/kelar/inputmethod/latin/utils/DebugLogUtils.java new file mode 100644 index 000000000..6587304ac --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/DebugLogUtils.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import org.kelar.inputmethod.latin.define.DebugFlags; + +/** + * A class for logging and debugging utility methods. + */ +public final class DebugLogUtils { + private final static String TAG = DebugLogUtils.class.getSimpleName(); + private final static boolean sDBG = DebugFlags.DEBUG_ENABLED; + + /** + * Calls .toString() on its non-null argument or returns "null" + * @param o the object to convert to a string + * @return the result of .toString() or null + */ + public static String s(final Object o) { + return null == o ? "null" : o.toString(); + } + + /** + * Get the string representation of the current stack trace, for debugging purposes. + * @return a readable, carriage-return-separated string for the current stack trace. + */ + public static String getStackTrace() { + return getStackTrace(Integer.MAX_VALUE - 1); + } + + /** + * Get the string representation of the current stack trace, for debugging purposes. + * @param limit the maximum number of stack frames to be returned. + * @return a readable, carriage-return-separated string for the current stack trace. + */ + public static String getStackTrace(final int limit) { + final StringBuilder sb = new StringBuilder(); + try { + throw new RuntimeException(); + } catch (final RuntimeException e) { + final StackTraceElement[] frames = e.getStackTrace(); + // Start at 1 because the first frame is here and we don't care about it + for (int j = 1; j < frames.length && j < limit + 1; ++j) { + sb.append(frames[j].toString() + "\n"); + } + } + return sb.toString(); + } + + /** + * Get the stack trace contained in an exception as a human-readable string. + * @param t the throwable + * @return the human-readable stack trace + */ + public static String getStackTrace(final Throwable t) { + final StringBuilder sb = new StringBuilder(); + final StackTraceElement[] frames = t.getStackTrace(); + for (int j = 0; j < frames.length; ++j) { + sb.append(frames[j].toString() + "\n"); + } + return sb.toString(); + } + + /** + * Helper log method to ease null-checks and adding spaces. + * + * This sends all arguments to the log, separated by spaces. Any null argument is converted + * to the "null" string. It uses a very visible tag and log level for debugging purposes. + * + * @param args the stuff to send to the log + */ + public static void l(final Object... args) { + if (!sDBG) return; + final StringBuilder sb = new StringBuilder(); + for (final Object o : args) { + sb.append(s(o).toString()); + sb.append(" "); + } + Log.e(TAG, sb.toString()); + } + + /** + * Helper log method to put stuff in red. + * + * This does the same as #l but prints in red + * + * @param args the stuff to send to the log + */ + public static void r(final Object... args) { + if (!sDBG) return; + final StringBuilder sb = new StringBuilder("\u001B[31m"); + for (final Object o : args) { + sb.append(s(o).toString()); + sb.append(" "); + } + sb.append("\u001B[0m"); + Log.e(TAG, sb.toString()); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/DialogUtils.java b/java/src/org/kelar/inputmethod/latin/utils/DialogUtils.java new file mode 100644 index 000000000..37a3fe57a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/DialogUtils.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; +import android.view.ContextThemeWrapper; + +import org.kelar.inputmethod.latin.R; + +public final class DialogUtils { + private DialogUtils() { + // This utility class is not publicly instantiable. + } + + public static Context getPlatformDialogThemeContext(final Context context) { + // Because {@link AlertDialog.Builder.create()} doesn't honor the specified theme with + // createThemeContextWrapper=false, the result dialog box has unneeded paddings around it. + return new ContextThemeWrapper(context, R.style.platformDialogTheme); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/DictionaryHeaderUtils.java b/java/src/org/kelar/inputmethod/latin/utils/DictionaryHeaderUtils.java new file mode 100644 index 000000000..0c0843e11 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/DictionaryHeaderUtils.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2015 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.AssetFileAddress; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; + +import java.io.File; + +public class DictionaryHeaderUtils { + + public static int getContentVersion(AssetFileAddress fileAddress) { + final DictionaryHeader header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull( + new File(fileAddress.mFilename), fileAddress.mOffset, fileAddress.mLength); + return Integer.parseInt(header.mVersionString); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/DictionaryInfoUtils.java b/java/src/org/kelar/inputmethod/latin/utils/DictionaryInfoUtils.java new file mode 100644 index 000000000..1cec4ff78 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/DictionaryInfoUtils.java @@ -0,0 +1,613 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.AssetManager; +import android.content.res.Resources; +import android.text.TextUtils; +import android.util.Log; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.dictionarypack.UpdateHandler; +import org.kelar.inputmethod.latin.AssetFileAddress; +import org.kelar.inputmethod.latin.BinaryDictionaryGetter; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.common.FileUtils; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.makedict.DictionaryHeader; +import org.kelar.inputmethod.latin.makedict.UnsupportedFormatException; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; + +import java.io.File; +import java.io.FilenameFilter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * 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(); + public static final String RESOURCE_PACKAGE_NAME = R.class.getPackage().getName(); + private static final String DEFAULT_MAIN_DICT = "main"; + private static final String MAIN_DICT_PREFIX = "main_"; + private static final String DECODER_DICT_SUFFIX = DecoderSpecificConstants.DECODER_DICT_SUFFIX; + // 6 digits - unicode is limited to 21 bits + private static final int MAX_HEX_DIGITS_FOR_CODEPOINT = 6; + + private static final String TEMP_DICT_FILE_SUB = UpdateHandler.TEMP_DICT_FILE_SUB; + + public static class DictionaryInfo { + private static final String LOCALE_COLUMN = "locale"; + private static final String WORDLISTID_COLUMN = "id"; + private static final String LOCAL_FILENAME_COLUMN = "filename"; + private static final String DESCRIPTION_COLUMN = "description"; + private static final String DATE_COLUMN = "date"; + private static final String FILESIZE_COLUMN = "filesize"; + private static final String VERSION_COLUMN = "version"; + + @Nonnull public final String mId; + @Nonnull public final Locale mLocale; + @Nullable public final String mDescription; + @Nullable public final String mFilename; + public final long mFilesize; + public final long mModifiedTimeMillis; + public final int mVersion; + + public DictionaryInfo(@Nonnull String id, @Nonnull Locale locale, + @Nullable String description, @Nullable String filename, + long filesize, long modifiedTimeMillis, int version) { + mId = id; + mLocale = locale; + mDescription = description; + mFilename = filename; + mFilesize = filesize; + mModifiedTimeMillis = modifiedTimeMillis; + mVersion = version; + } + + public ContentValues toContentValues() { + final ContentValues values = new ContentValues(); + values.put(WORDLISTID_COLUMN, mId); + values.put(LOCALE_COLUMN, mLocale.toString()); + values.put(DESCRIPTION_COLUMN, mDescription); + values.put(LOCAL_FILENAME_COLUMN, mFilename != null ? mFilename : ""); + values.put(DATE_COLUMN, TimeUnit.MILLISECONDS.toSeconds(mModifiedTimeMillis)); + values.put(FILESIZE_COLUMN, mFilesize); + values.put(VERSION_COLUMN, mVersion); + return values; + } + + @Override + public String toString() { + return "DictionaryInfo : Id = '" + mId + + "' : Locale=" + mLocale + + " : Version=" + mVersion; + } + } + + 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"; + } + + /** + * Helper method to get the top level cache directory. + */ + public static String getWordListStagingDirectory(final Context context) { + return context.getFilesDir() + File.separator + "staging"; + } + + /** + * Helper method to get the top level temp directory. + */ + public static String getWordListTempDirectory(final Context context) { + return context.getFilesDir() + File.separator + "tmp"; + } + + /** + * Reverse escaping done by {@link #replaceFileNameDangerousCharacters(String)}. + */ + @Nonnull + public static String getWordListIdFromFileName(@Nonnull 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(); + } + + public static File[] getStagingDirectoryList(final Context context) { + return new File(DictionaryInfoUtils.getWordListStagingDirectory(context)).listFiles(); + } + + @Nullable + public static File[] getUnusedDictionaryList(final Context context) { + return context.getFilesDir().listFiles(new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) { + return !TextUtils.isEmpty(filename) && filename.endsWith(".dict") + && filename.contains(TEMP_DICT_FILE_SUB); + } + }); + } + + /** + * 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. + */ + @Nullable + public static String getCategoryFromFileName(@Nonnull 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. + */ + public 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 String getStagingFileName(String id, String locale, Context context) { + final String stagingDirectory = getWordListStagingDirectory(context); + // create the directory if it does not exist. + final File directory = new File(stagingDirectory); + if (!directory.exists()) { + if (!directory.mkdirs()) { + Log.e(TAG, "Could not create the staging directory."); + } + } + // e.g. id="main:en_in", locale ="en_IN" + final String fileName = replaceFileNameDangerousCharacters( + locale + TEMP_DICT_FILE_SUB + id); + return stagingDirectory + File.separator + fileName; + } + + public static void moveStagingFilesIfExists(Context context) { + final File[] stagingFiles = DictionaryInfoUtils.getStagingDirectoryList(context); + if (stagingFiles != null && stagingFiles.length > 0) { + for (final File stagingFile : stagingFiles) { + final String fileName = stagingFile.getName(); + final int index = fileName.indexOf(TEMP_DICT_FILE_SUB); + if (index == -1) { + // This should never happen. + Log.e(TAG, "Staging file does not have ___ substring."); + continue; + } + final String[] localeAndFileId = fileName.split(TEMP_DICT_FILE_SUB); + if (localeAndFileId.length != 2) { + Log.e(TAG, String.format("malformed staging file %s. Deleting.", + stagingFile.getAbsoluteFile())); + stagingFile.delete(); + continue; + } + + final String locale = localeAndFileId[0]; + // already escaped while moving to staging. + final String fileId = localeAndFileId[1]; + final String cacheDirectoryForLocale = getCacheDirectoryForLocale(locale, context); + final String cacheFilename = cacheDirectoryForLocale + File.separator + fileId; + final File cacheFile = new File(cacheFilename); + // move the staging file to cache file. + if (!FileUtils.renameTo(stagingFile, cacheFile)) { + Log.e(TAG, String.format("Failed to rename from %s to %s.", + stagingFile.getAbsoluteFile(), cacheFile.getAbsoluteFile())); + } + } + } + } + + 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]); + } + + /** + * Find out whether a dictionary is available for this locale. + * @param context the context on which to check resources. + * @param locale the locale to check for. + * @return whether a (non-placeholder) dictionary is available or not. + */ + public static boolean isDictionaryAvailable(final Context context, final Locale locale) { + final Resources res = context.getResources(); + return 0 != getMainDictionaryResourceIdIfAvailableForLocale(res, locale); + } + + /** + * Helper method to return a dictionary res id for a locale, or 0 if none. + * @param res resources for the app + * @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) + DECODER_DICT_SUFFIX; + 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() + DECODER_DICT_SUFFIX; + 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 res resources for the app + * @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 + DecoderSpecificConstants.DECODER_DICT_SUFFIX, + "raw", RESOURCE_PACKAGE_NAME); + } + + /** + * Returns the id associated with the main word list for a specified locale. + * + * Word lists stored in Kelar 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(@Nonnull 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.toString().toLowerCase(); + } + + public static DictionaryHeader getDictionaryFileHeaderOrNull(final File file, + final long offset, final long length) { + try { + final DictionaryHeader header = + BinaryDictionaryUtils.getHeaderWithOffsetAndLength(file, offset, length); + return header; + } catch (UnsupportedFormatException e) { + return null; + } catch (IOException e) { + return null; + } + } + + /** + * Returns information of the dictionary. + * + * @param fileAddress the asset dictionary file address. + * @param locale Locale for this file. + * @return information of the specified dictionary. + */ + private static DictionaryInfo createDictionaryInfoFromFileAddress( + @Nonnull final AssetFileAddress fileAddress, final Locale locale) { + final String id = getMainDictId(locale); + final int version = DictionaryHeaderUtils.getContentVersion(fileAddress); + final String description = SubtypeLocaleUtils + .getSubtypeLocaleDisplayName(locale.toString()); + // Do not store the filename on db as it will try to move the filename from db to the + // cached directory. If the filename is already in cached directory, this is not + // necessary. + final String filenameToStoreOnDb = null; + return new DictionaryInfo(id, locale, description, filenameToStoreOnDb, + fileAddress.mLength, new File(fileAddress.mFilename).lastModified(), version); + } + + /** + * Returns the information of the dictionary for the given {@link AssetFileAddress}. + * If the file is corrupted or a pre-fava file, then the file gets deleted and the null + * value is returned. + */ + @Nullable + private static DictionaryInfo createDictionaryInfoForUnCachedFile( + @Nonnull final AssetFileAddress fileAddress, final Locale locale) { + final String id = getMainDictId(locale); + final int version = DictionaryHeaderUtils.getContentVersion(fileAddress); + + if (version == -1) { + // Purge the pre-fava/corrupted unused dictionaires. + fileAddress.deleteUnderlyingFile(); + return null; + } + + final String description = SubtypeLocaleUtils + .getSubtypeLocaleDisplayName(locale.toString()); + + final File unCachedFile = new File(fileAddress.mFilename); + // Store just the filename and not the full path. + final String filenameToStoreOnDb = unCachedFile.getName(); + return new DictionaryInfo(id, locale, description, filenameToStoreOnDb, fileAddress.mLength, + unCachedFile.lastModified(), version); + } + + /** + * Returns dictionary information for the given locale. + */ + private static DictionaryInfo createDictionaryInfoFromLocale(Locale locale) { + final String id = getMainDictId(locale); + final int version = -1; + final String description = SubtypeLocaleUtils + .getSubtypeLocaleDisplayName(locale.toString()); + return new DictionaryInfo(id, locale, description, null, 0L, 0L, version); + } + + private static void addOrUpdateDictInfo(final ArrayList<DictionaryInfo> dictList, + final DictionaryInfo newElement) { + final Iterator<DictionaryInfo> iter = dictList.iterator(); + while (iter.hasNext()) { + final DictionaryInfo thisDictInfo = iter.next(); + if (thisDictInfo.mLocale.equals(newElement.mLocale)) { + if (newElement.mVersion <= thisDictInfo.mVersion) { + return; + } + iter.remove(); + } + } + dictList.add(newElement); + } + + public static ArrayList<DictionaryInfo> getCurrentDictionaryFileNameAndVersionInfo( + final Context context) { + final ArrayList<DictionaryInfo> dictList = new ArrayList<>(); + + // Retrieve downloaded dictionaries from cached directories + final File[] directoryList = getCachedDirectoryList(context); + if (null != directoryList) { + for (final File directory : directoryList) { + final String localeString = getWordListIdFromFileName(directory.getName()); + final File[] dicts = BinaryDictionaryGetter.getCachedWordLists( + localeString, context); + for (final File dict : dicts) { + final String wordListId = getWordListIdFromFileName(dict.getName()); + if (!DictionaryInfoUtils.isMainWordListId(wordListId)) { + continue; + } + final Locale locale = LocaleUtils.constructLocaleFromString(localeString); + final AssetFileAddress fileAddress = AssetFileAddress.makeFromFile(dict); + final DictionaryInfo dictionaryInfo = + createDictionaryInfoFromFileAddress(fileAddress, locale); + // Protect against cases of a less-specific dictionary being found, like an + // en dictionary being used for an en_US locale. In this case, the en dictionary + // should be used for en_US but discounted for listing purposes. + if (dictionaryInfo == null || !dictionaryInfo.mLocale.equals(locale)) { + continue; + } + addOrUpdateDictInfo(dictList, dictionaryInfo); + } + } + } + + // Retrieve downloaded dictionaries from the unused dictionaries. + File[] unusedDictionaryList = getUnusedDictionaryList(context); + if (unusedDictionaryList != null) { + for (File dictionaryFile : unusedDictionaryList) { + String fileName = dictionaryFile.getName(); + int index = fileName.indexOf(TEMP_DICT_FILE_SUB); + if (index == -1) { + continue; + } + String locale = fileName.substring(0, index); + DictionaryInfo dictionaryInfo = createDictionaryInfoForUnCachedFile( + AssetFileAddress.makeFromFile(dictionaryFile), + LocaleUtils.constructLocaleFromString(locale)); + if (dictionaryInfo != null) { + addOrUpdateDictInfo(dictList, dictionaryInfo); + } + } + } + + // Retrieve files from assets + final Resources resources = context.getResources(); + final AssetManager assets = resources.getAssets(); + for (final String localeString : assets.getLocales()) { + final Locale locale = LocaleUtils.constructLocaleFromString(localeString); + final int resourceId = + DictionaryInfoUtils.getMainDictionaryResourceIdIfAvailableForLocale( + context.getResources(), locale); + if (0 == resourceId) { + continue; + } + final AssetFileAddress fileAddress = + BinaryDictionaryGetter.loadFallbackResource(context, resourceId); + final DictionaryInfo dictionaryInfo = createDictionaryInfoFromFileAddress(fileAddress, + locale); + // Protect against cases of a less-specific dictionary being found, like an + // en dictionary being used for an en_US locale. In this case, the en dictionary + // should be used for en_US but discounted for listing purposes. + // TODO: Remove dictionaryInfo == null when the static LMs have the headers. + if (dictionaryInfo == null || !dictionaryInfo.mLocale.equals(locale)) { + continue; + } + addOrUpdateDictInfo(dictList, dictionaryInfo); + } + + // Generate the dictionary information from the enabled subtypes. This will not + // overwrite the real records. + RichInputMethodManager.init(context); + List<InputMethodSubtype> enabledSubtypes = RichInputMethodManager + .getInstance().getMyEnabledInputMethodSubtypeList(true); + for (InputMethodSubtype subtype : enabledSubtypes) { + Locale locale = LocaleUtils.constructLocaleFromString(subtype.getLocale()); + DictionaryInfo dictionaryInfo = createDictionaryInfoFromLocale(locale); + addOrUpdateDictInfo(dictList, dictionaryInfo); + } + + return dictList; + } + + @UsedForTesting + public static boolean looksValidForDictionaryInsertion(final CharSequence text, + final SpacingAndPunctuations spacingAndPunctuations) { + if (TextUtils.isEmpty(text)) { + return false; + } + final int length = text.length(); + if (length > DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH) { + return false; + } + int i = 0; + int digitCount = 0; + while (i < length) { + final int codePoint = Character.codePointAt(text, i); + final int charCount = Character.charCount(codePoint); + i += charCount; + if (Character.isDigit(codePoint)) { + // Count digits: see below + digitCount += charCount; + continue; + } + if (!spacingAndPunctuations.isWordCodePoint(codePoint)) { + return false; + } + } + // We reject strings entirely comprised of digits to avoid using PIN codes or credit + // card numbers. It would come in handy for word prediction though; a good example is + // when writing one's address where the street number is usually quite discriminative, + // as well as the postal code. + return digitCount < length; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ExecutorUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ExecutorUtils.java new file mode 100644 index 000000000..2432febdd --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ExecutorUtils.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.lang.Thread.UncaughtExceptionHandler; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Utilities to manage executors. + */ +public class ExecutorUtils { + + private static final String TAG = "ExecutorUtils"; + + public static final String KEYBOARD = "Keyboard"; + public static final String SPELLING = "Spelling"; + + private static ScheduledExecutorService sKeyboardExecutorService = newExecutorService(KEYBOARD); + private static ScheduledExecutorService sSpellingExecutorService = newExecutorService(SPELLING); + + private static ScheduledExecutorService newExecutorService(final String name) { + return Executors.newSingleThreadScheduledExecutor(new ExecutorFactory(name)); + } + + private static class ExecutorFactory implements ThreadFactory { + private final String mName; + + private ExecutorFactory(final String name) { + mName = name; + } + + @Override + public Thread newThread(final Runnable runnable) { + Thread thread = new Thread(runnable, TAG); + thread.setUncaughtExceptionHandler(new UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread thread, Throwable ex) { + Log.w(mName + "-" + runnable.getClass().getSimpleName(), ex); + } + }); + return thread; + } + } + + @UsedForTesting + private static ScheduledExecutorService sExecutorServiceForTests; + + @UsedForTesting + public static void setExecutorServiceForTests( + final ScheduledExecutorService executorServiceForTests) { + sExecutorServiceForTests = executorServiceForTests; + } + + // + // Public methods used to schedule a runnable for execution. + // + + /** + * @param name Executor's name. + * @return scheduled executor service used to run background tasks + */ + public static ScheduledExecutorService getBackgroundExecutor(final String name) { + if (sExecutorServiceForTests != null) { + return sExecutorServiceForTests; + } + switch (name) { + case KEYBOARD: + return sKeyboardExecutorService; + case SPELLING: + return sSpellingExecutorService; + default: + throw new IllegalArgumentException("Invalid executor: " + name); + } + } + + public static void killTasks(final String name) { + final ScheduledExecutorService executorService = getBackgroundExecutor(name); + executorService.shutdownNow(); + try { + executorService.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Log.wtf(TAG, "Failed to shut down: " + name); + } + if (executorService == sExecutorServiceForTests) { + // Don't do anything to the test service. + return; + } + switch (name) { + case KEYBOARD: + sKeyboardExecutorService = newExecutorService(KEYBOARD); + break; + case SPELLING: + sSpellingExecutorService = newExecutorService(SPELLING); + break; + default: + throw new IllegalArgumentException("Invalid executor: " + name); + } + } + + @UsedForTesting + public static Runnable chain(final Runnable... runnables) { + return new RunnableChain(runnables); + } + + @UsedForTesting + public static class RunnableChain implements Runnable { + private final Runnable[] mRunnables; + + private RunnableChain(final Runnable... runnables) { + if (runnables == null || runnables.length == 0) { + throw new IllegalArgumentException("Attempting to construct an empty chain"); + } + mRunnables = runnables; + } + + @UsedForTesting + public Runnable[] getRunnables() { + return mRunnables; + } + + @Override + public void run() { + for (Runnable runnable : mRunnables) { + if (Thread.interrupted()) { + return; + } + runnable.run(); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/FeedbackUtils.java b/java/src/org/kelar/inputmethod/latin/utils/FeedbackUtils.java new file mode 100644 index 000000000..72308c85f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/FeedbackUtils.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.kelar.inputmethod.latin.utils; + +import android.content.Context; +import android.content.Intent; + +@SuppressWarnings("unused") +public class FeedbackUtils { + public static boolean isHelpAndFeedbackFormSupported() { + return false; + } + + public static void showHelpAndFeedbackForm(Context context) { + } + + public static int getAboutKeyboardTitleResId() { + return 0; + } + + public static Intent getAboutKeyboardIntent(Context context) { + return null; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/FileTransforms.java b/java/src/org/kelar/inputmethod/latin/utils/FileTransforms.java new file mode 100644 index 000000000..5f918410d --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/FileTransforms.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.zip.GZIPInputStream; + +public final class FileTransforms { + public static OutputStream getCryptedStream(OutputStream out) { + // Crypt the stream. + return out; + } + + public static InputStream getDecryptedStream(InputStream in) { + // Decrypt the stream. + return in; + } + + public static InputStream getUncompressedStream(InputStream in) throws IOException { + return new GZIPInputStream(in); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/FragmentUtils.java b/java/src/org/kelar/inputmethod/latin/utils/FragmentUtils.java new file mode 100644 index 000000000..f015c7f73 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/FragmentUtils.java @@ -0,0 +1,64 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.dictionarypack.DictionarySettingsFragment; +import org.kelar.inputmethod.latin.about.AboutPreferences; +import org.kelar.inputmethod.latin.settings.AccountsSettingsFragment; +import org.kelar.inputmethod.latin.settings.AdvancedSettingsFragment; +import org.kelar.inputmethod.latin.settings.AppearanceSettingsFragment; +import org.kelar.inputmethod.latin.settings.CorrectionSettingsFragment; +import org.kelar.inputmethod.latin.settings.CustomInputStyleSettingsFragment; +import org.kelar.inputmethod.latin.settings.DebugSettingsFragment; +import org.kelar.inputmethod.latin.settings.GestureSettingsFragment; +import org.kelar.inputmethod.latin.settings.PreferencesSettingsFragment; +import org.kelar.inputmethod.latin.settings.SettingsFragment; +import org.kelar.inputmethod.latin.settings.ThemeSettingsFragment; +import org.kelar.inputmethod.latin.spellcheck.SpellCheckerSettingsFragment; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryAddWordFragment; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryList; +import org.kelar.inputmethod.latin.userdictionary.UserDictionaryLocalePicker; +import org.kelar.inputmethod.latin.userdictionary.UserDictionarySettings; + +import java.util.HashSet; + +public class FragmentUtils { + private static final HashSet<String> sLatinImeFragments = new HashSet<>(); + static { + sLatinImeFragments.add(DictionarySettingsFragment.class.getName()); + sLatinImeFragments.add(AboutPreferences.class.getName()); + sLatinImeFragments.add(PreferencesSettingsFragment.class.getName()); + sLatinImeFragments.add(AccountsSettingsFragment.class.getName()); + sLatinImeFragments.add(AppearanceSettingsFragment.class.getName()); + sLatinImeFragments.add(ThemeSettingsFragment.class.getName()); + sLatinImeFragments.add(CustomInputStyleSettingsFragment.class.getName()); + sLatinImeFragments.add(GestureSettingsFragment.class.getName()); + sLatinImeFragments.add(CorrectionSettingsFragment.class.getName()); + sLatinImeFragments.add(AdvancedSettingsFragment.class.getName()); + sLatinImeFragments.add(DebugSettingsFragment.class.getName()); + sLatinImeFragments.add(SettingsFragment.class.getName()); + sLatinImeFragments.add(SpellCheckerSettingsFragment.class.getName()); + sLatinImeFragments.add(UserDictionaryAddWordFragment.class.getName()); + sLatinImeFragments.add(UserDictionaryList.class.getName()); + sLatinImeFragments.add(UserDictionaryLocalePicker.class.getName()); + sLatinImeFragments.add(UserDictionarySettings.class.getName()); + } + + public static boolean isValidFragment(String fragmentName) { + return sLatinImeFragments.contains(fragmentName); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ImportantNoticeUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ImportantNoticeUtils.java new file mode 100644 index 000000000..d006cd3d5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ImportantNoticeUtils.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.Manifest; +import android.content.Context; +import android.content.SharedPreferences; +import android.provider.Settings; +import android.provider.Settings.SettingNotFoundException; +import android.text.TextUtils; +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.permissions.PermissionsUtil; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +import java.util.concurrent.TimeUnit; + +public final class ImportantNoticeUtils { + private static final String TAG = ImportantNoticeUtils.class.getSimpleName(); + + // {@link SharedPreferences} name to save the last important notice version that has been + // displayed to users. + private static final String PREFERENCE_NAME = "important_notice_pref"; + + private static final String KEY_SUGGEST_CONTACTS_NOTICE = "important_notice_suggest_contacts"; + + @UsedForTesting + static final String KEY_TIMESTAMP_OF_CONTACTS_NOTICE = "timestamp_of_suggest_contacts_notice"; + + @UsedForTesting + static final long TIMEOUT_OF_IMPORTANT_NOTICE = TimeUnit.HOURS.toMillis(23); + + // Copy of the hidden {@link Settings.Secure#USER_SETUP_COMPLETE} settings key. + // The value is zero until each multiuser completes system setup wizard. + // Caveat: This is a hidden API. + private static final String Settings_Secure_USER_SETUP_COMPLETE = "user_setup_complete"; + private static final int USER_SETUP_IS_NOT_COMPLETE = 0; + + private ImportantNoticeUtils() { + // This utility class is not publicly instantiable. + } + + @UsedForTesting + static boolean isInSystemSetupWizard(final Context context) { + try { + final int userSetupComplete = Settings.Secure.getInt( + context.getContentResolver(), Settings_Secure_USER_SETUP_COMPLETE); + return userSetupComplete == USER_SETUP_IS_NOT_COMPLETE; + } catch (final SettingNotFoundException e) { + Log.w(TAG, "Can't find settings in Settings.Secure: key=" + + Settings_Secure_USER_SETUP_COMPLETE); + return false; + } + } + + @UsedForTesting + static SharedPreferences getImportantNoticePreferences(final Context context) { + return context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); + } + + @UsedForTesting + static boolean hasContactsNoticeShown(final Context context) { + return getImportantNoticePreferences(context).getBoolean( + KEY_SUGGEST_CONTACTS_NOTICE, false); + } + + public static boolean shouldShowImportantNotice(final Context context, + final SettingsValues settingsValues) { + // Check to see whether "Use Contacts" is enabled by the user. + if (!settingsValues.mUseContactsDict) { + return false; + } + + if (hasContactsNoticeShown(context)) { + return false; + } + + // Don't show the dialog if we have all the permissions. + if (PermissionsUtil.checkAllPermissionsGranted( + context, Manifest.permission.READ_CONTACTS)) { + return false; + } + + final String importantNoticeTitle = getSuggestContactsNoticeTitle(context); + if (TextUtils.isEmpty(importantNoticeTitle)) { + return false; + } + if (isInSystemSetupWizard(context)) { + return false; + } + if (hasContactsNoticeTimeoutPassed(context, System.currentTimeMillis())) { + updateContactsNoticeShown(context); + return false; + } + return true; + } + + public static String getSuggestContactsNoticeTitle(final Context context) { + return context.getResources().getString(R.string.important_notice_suggest_contact_names); + } + + @UsedForTesting + static boolean hasContactsNoticeTimeoutPassed( + final Context context, final long currentTimeInMillis) { + final SharedPreferences prefs = getImportantNoticePreferences(context); + if (!prefs.contains(KEY_TIMESTAMP_OF_CONTACTS_NOTICE)) { + prefs.edit() + .putLong(KEY_TIMESTAMP_OF_CONTACTS_NOTICE, currentTimeInMillis) + .apply(); + } + final long firstDisplayTimeInMillis = prefs.getLong( + KEY_TIMESTAMP_OF_CONTACTS_NOTICE, currentTimeInMillis); + final long elapsedTime = currentTimeInMillis - firstDisplayTimeInMillis; + return elapsedTime >= TIMEOUT_OF_IMPORTANT_NOTICE; + } + + public static void updateContactsNoticeShown(final Context context) { + getImportantNoticePreferences(context) + .edit() + .putBoolean(KEY_SUGGEST_CONTACTS_NOTICE, true) + .remove(KEY_TIMESTAMP_OF_CONTACTS_NOTICE) + .apply(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/InputTypeUtils.java b/java/src/org/kelar/inputmethod/latin/utils/InputTypeUtils.java new file mode 100644 index 000000000..3a4bae78c --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/InputTypeUtils.java @@ -0,0 +1,117 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.text.InputType; +import android.view.inputmethod.EditorInfo; + +public final class InputTypeUtils implements InputType { + private static final int WEB_TEXT_PASSWORD_INPUT_TYPE = + TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_WEB_PASSWORD; + private static final int WEB_TEXT_EMAIL_ADDRESS_INPUT_TYPE = + TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS; + private static final int NUMBER_PASSWORD_INPUT_TYPE = + TYPE_CLASS_NUMBER | TYPE_NUMBER_VARIATION_PASSWORD; + private static final int TEXT_PASSWORD_INPUT_TYPE = + TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_PASSWORD; + private static final int TEXT_VISIBLE_PASSWORD_INPUT_TYPE = + TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_VISIBLE_PASSWORD; + private static final int[] SUPPRESSING_AUTO_SPACES_FIELD_VARIATION = { + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD }; + public static final int IME_ACTION_CUSTOM_LABEL = EditorInfo.IME_MASK_ACTION + 1; + + private InputTypeUtils() { + // This utility class is not publicly instantiable. + } + + private static boolean isWebEditTextInputType(final int inputType) { + return inputType == (TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_WEB_EDIT_TEXT); + } + + private static boolean isWebPasswordInputType(final int inputType) { + return WEB_TEXT_PASSWORD_INPUT_TYPE != 0 + && inputType == WEB_TEXT_PASSWORD_INPUT_TYPE; + } + + private static boolean isWebEmailAddressInputType(final int inputType) { + return WEB_TEXT_EMAIL_ADDRESS_INPUT_TYPE != 0 + && inputType == WEB_TEXT_EMAIL_ADDRESS_INPUT_TYPE; + } + + private static boolean isNumberPasswordInputType(final int inputType) { + return NUMBER_PASSWORD_INPUT_TYPE != 0 + && inputType == NUMBER_PASSWORD_INPUT_TYPE; + } + + private static boolean isTextPasswordInputType(final int inputType) { + return inputType == TEXT_PASSWORD_INPUT_TYPE; + } + + private static boolean isWebEmailAddressVariation(int variation) { + return variation == TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS; + } + + public static boolean isEmailVariation(final int variation) { + return variation == TYPE_TEXT_VARIATION_EMAIL_ADDRESS + || isWebEmailAddressVariation(variation); + } + + public static boolean isWebInputType(final int inputType) { + final int maskedInputType = + inputType & (TYPE_MASK_CLASS | TYPE_MASK_VARIATION); + return isWebEditTextInputType(maskedInputType) || isWebPasswordInputType(maskedInputType) + || isWebEmailAddressInputType(maskedInputType); + } + + // Please refer to TextView.isPasswordInputType + public static boolean isPasswordInputType(final int inputType) { + final int maskedInputType = + inputType & (TYPE_MASK_CLASS | TYPE_MASK_VARIATION); + return isTextPasswordInputType(maskedInputType) || isWebPasswordInputType(maskedInputType) + || isNumberPasswordInputType(maskedInputType); + } + + // Please refer to TextView.isVisiblePasswordInputType + public static boolean isVisiblePasswordInputType(final int inputType) { + final int maskedInputType = + inputType & (TYPE_MASK_CLASS | TYPE_MASK_VARIATION); + return maskedInputType == TEXT_VISIBLE_PASSWORD_INPUT_TYPE; + } + + public static boolean isAutoSpaceFriendlyType(final int inputType) { + if (TYPE_CLASS_TEXT != (TYPE_MASK_CLASS & inputType)) return false; + final int variation = TYPE_MASK_VARIATION & inputType; + for (final int fieldVariation : SUPPRESSING_AUTO_SPACES_FIELD_VARIATION) { + if (variation == fieldVariation) return false; + } + return true; + } + + public static int getImeOptionsActionIdFromEditorInfo(final EditorInfo editorInfo) { + if ((editorInfo.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { + return EditorInfo.IME_ACTION_NONE; + } else if (editorInfo.actionLabel != null) { + return IME_ACTION_CUSTOM_LABEL; + } else { + // Note: this is different from editorInfo.actionId, hence "ImeOptionsActionId" + return editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/IntentUtils.java b/java/src/org/kelar/inputmethod/latin/utils/IntentUtils.java new file mode 100644 index 000000000..48e4b69e5 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/IntentUtils.java @@ -0,0 +1,45 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.content.Intent; +import android.text.TextUtils; + +public final class IntentUtils { + private static final String EXTRA_INPUT_METHOD_ID = "input_method_id"; + // TODO: Can these be constants instead of literal String constants? + private static final String INPUT_METHOD_SUBTYPE_SETTINGS = + "android.settings.INPUT_METHOD_SUBTYPE_SETTINGS"; + + private IntentUtils() { + // This utility class is not publicly instantiable. + } + + public static Intent getInputLanguageSelectionIntent(final String inputMethodId, + final int flagsForSubtypeSettings) { + // Refer to android.provider.Settings.ACTION_INPUT_METHOD_SUBTYPE_SETTINGS + final String action = INPUT_METHOD_SUBTYPE_SETTINGS; + final Intent intent = new Intent(action); + if (!TextUtils.isEmpty(inputMethodId)) { + intent.putExtra(EXTRA_INPUT_METHOD_ID, inputMethodId); + } + if (flagsForSubtypeSettings > 0) { + intent.setFlags(flagsForSubtypeSettings); + } + return intent; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/JniUtils.java b/java/src/org/kelar/inputmethod/latin/utils/JniUtils.java new file mode 100644 index 000000000..c7506ca7b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/JniUtils.java @@ -0,0 +1,41 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import org.kelar.inputmethod.latin.define.JniLibName; + +public final class JniUtils { + private static final String TAG = JniUtils.class.getSimpleName(); + + static { + try { + System.loadLibrary(JniLibName.JNI_LIB_NAME); + } catch (UnsatisfiedLinkError ule) { + Log.e(TAG, "Could not load native library " + JniLibName.JNI_LIB_NAME, ule); + } + } + + private JniUtils() { + // This utility class is not publicly instantiable. + } + + public static void loadNativeLibrary() { + // Ensures the static initializer is called + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/JsonUtils.java b/java/src/org/kelar/inputmethod/latin/utils/JsonUtils.java new file mode 100644 index 000000000..7a2d2d92f --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/JsonUtils.java @@ -0,0 +1,103 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.util.JsonReader; +import android.util.JsonWriter; +import android.util.Log; + +import java.io.Closeable; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class JsonUtils { + private static final String TAG = JsonUtils.class.getSimpleName(); + + private static final String INTEGER_CLASS_NAME = Integer.class.getSimpleName(); + private static final String STRING_CLASS_NAME = String.class.getSimpleName(); + + private static final String EMPTY_STRING = ""; + + public static List<Object> jsonStrToList(final String s) { + final ArrayList<Object> list = new ArrayList<>(); + final JsonReader reader = new JsonReader(new StringReader(s)); + try { + reader.beginArray(); + while (reader.hasNext()) { + reader.beginObject(); + while (reader.hasNext()) { + final String name = reader.nextName(); + if (name.equals(INTEGER_CLASS_NAME)) { + list.add(reader.nextInt()); + } else if (name.equals(STRING_CLASS_NAME)) { + list.add(reader.nextString()); + } else { + Log.w(TAG, "Invalid name: " + name); + reader.skipValue(); + } + } + reader.endObject(); + } + reader.endArray(); + return list; + } catch (final IOException e) { + } finally { + close(reader); + } + return Collections.<Object>emptyList(); + } + + public static String listToJsonStr(final List<Object> list) { + if (list == null || list.isEmpty()) { + return EMPTY_STRING; + } + final StringWriter sw = new StringWriter(); + final JsonWriter writer = new JsonWriter(sw); + try { + writer.beginArray(); + for (final Object o : list) { + writer.beginObject(); + if (o instanceof Integer) { + writer.name(INTEGER_CLASS_NAME).value((Integer)o); + } else if (o instanceof String) { + writer.name(STRING_CLASS_NAME).value((String)o); + } + writer.endObject(); + } + writer.endArray(); + return sw.toString(); + } catch (final IOException e) { + } finally { + close(writer); + } + return EMPTY_STRING; + } + + private static void close(final Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (final IOException e) { + // Ignore + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/LanguageOnSpacebarUtils.java b/java/src/org/kelar/inputmethod/latin/utils/LanguageOnSpacebarUtils.java new file mode 100644 index 000000000..2bcfc82b8 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/LanguageOnSpacebarUtils.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.latin.RichInputMethodSubtype; + +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import javax.annotation.Nonnull; + +/** + * This class determines that the language name on the spacebar should be displayed in what format. + */ +public final class LanguageOnSpacebarUtils { + public static final int FORMAT_TYPE_NONE = 0; + public static final int FORMAT_TYPE_LANGUAGE_ONLY = 1; + public static final int FORMAT_TYPE_FULL_LOCALE = 2; + + private static List<InputMethodSubtype> sEnabledSubtypes = Collections.emptyList(); + private static boolean sIsSystemLanguageSameAsInputLanguage; + + private LanguageOnSpacebarUtils() { + // This utility class is not publicly instantiable. + } + + public static int getLanguageOnSpacebarFormatType( + @Nonnull final RichInputMethodSubtype subtype) { + if (subtype.isNoLanguage()) { + return FORMAT_TYPE_FULL_LOCALE; + } + // Only this subtype is enabled and equals to the system locale. + if (sEnabledSubtypes.size() < 2 && sIsSystemLanguageSameAsInputLanguage) { + return FORMAT_TYPE_NONE; + } + final Locale locale = subtype.getLocale(); + if (locale == null) { + return FORMAT_TYPE_NONE; + } + final String keyboardLanguage = locale.getLanguage(); + final String keyboardLayout = subtype.getKeyboardLayoutSetName(); + int sameLanguageAndLayoutCount = 0; + for (final InputMethodSubtype ims : sEnabledSubtypes) { + final String language = SubtypeLocaleUtils.getSubtypeLocale(ims).getLanguage(); + if (keyboardLanguage.equals(language) && keyboardLayout.equals( + SubtypeLocaleUtils.getKeyboardLayoutSetName(ims))) { + sameLanguageAndLayoutCount++; + } + } + // Display full locale name only when there are multiple subtypes that have the same + // locale and keyboard layout. Otherwise displaying language name is enough. + return sameLanguageAndLayoutCount > 1 ? FORMAT_TYPE_FULL_LOCALE + : FORMAT_TYPE_LANGUAGE_ONLY; + } + + public static void setEnabledSubtypes(@Nonnull final List<InputMethodSubtype> enabledSubtypes) { + sEnabledSubtypes = enabledSubtypes; + } + + public static void onSubtypeChanged(@Nonnull final RichInputMethodSubtype subtype, + final boolean implicitlyEnabledSubtype, @Nonnull final Locale systemLocale) { + final Locale newLocale = subtype.getLocale(); + if (systemLocale.equals(newLocale)) { + sIsSystemLanguageSameAsInputLanguage = true; + return; + } + if (!systemLocale.getLanguage().equals(newLocale.getLanguage())) { + sIsSystemLanguageSameAsInputLanguage = false; + return; + } + // If the subtype is enabled explicitly, the language name should be displayed even when + // the keyboard language and the system language are equal. + sIsSystemLanguageSameAsInputLanguage = implicitlyEnabledSubtype; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/LeakGuardHandlerWrapper.java b/java/src/org/kelar/inputmethod/latin/utils/LeakGuardHandlerWrapper.java new file mode 100644 index 000000000..37f7c3023 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/LeakGuardHandlerWrapper.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import android.os.Handler; +import android.os.Looper; + +import java.lang.ref.WeakReference; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class LeakGuardHandlerWrapper<T> extends Handler { + private final WeakReference<T> mOwnerInstanceRef; + + public LeakGuardHandlerWrapper(@Nonnull final T ownerInstance) { + this(ownerInstance, Looper.myLooper()); + } + + public LeakGuardHandlerWrapper(@Nonnull final T ownerInstance, final Looper looper) { + super(looper); + mOwnerInstanceRef = new WeakReference<>(ownerInstance); + } + + @Nullable + public T getOwnerInstance() { + return mOwnerInstanceRef.get(); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ManagedProfileUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ManagedProfileUtils.java new file mode 100644 index 000000000..f0eb90ad6 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ManagedProfileUtils.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +public class ManagedProfileUtils { + private static ManagedProfileUtils INSTANCE = new ManagedProfileUtils(); + private static ManagedProfileUtils sTestInstance; + + private ManagedProfileUtils() { + // This utility class is not publicly instantiable. + } + + @UsedForTesting + public static void setTestInstance(final ManagedProfileUtils testInstance) { + sTestInstance = testInstance; + } + + public static ManagedProfileUtils getInstance() { + return sTestInstance == null ? INSTANCE : sTestInstance; + } + + public boolean hasWorkProfile(final Context context) { + return false; + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/utils/MetadataFileUriGetter.java b/java/src/org/kelar/inputmethod/latin/utils/MetadataFileUriGetter.java new file mode 100644 index 000000000..ae3108747 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/MetadataFileUriGetter.java @@ -0,0 +1,39 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.R; + +import android.content.Context; + +/** + * Helper class to get the metadata URI and the additional ID. + */ +@SuppressWarnings("unused") +public class MetadataFileUriGetter { + private MetadataFileUriGetter() { + // This helper class is not instantiable. + } + + public static String getMetadataUri(final Context context) { + return context.getString(R.string.dictionary_pack_metadata_uri); + } + + public static String getMetadataAdditionalId(final Context context) { + return ""; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/NgramContextUtils.java b/java/src/org/kelar/inputmethod/latin/utils/NgramContextUtils.java new file mode 100644 index 000000000..6f8437b06 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/NgramContextUtils.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.NgramContext.WordInfo; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; + +import java.util.Arrays; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +public final class NgramContextUtils { + private NgramContextUtils() { + // Intentional empty constructor for utility class. + } + + private static final Pattern NEWLINE_REGEX = Pattern.compile("[\\r\\n]+"); + private static final Pattern SPACE_REGEX = Pattern.compile("\\s+"); + // Get context information from nth word before the cursor. n = 1 retrieves the words + // immediately before the cursor, n = 2 retrieves the words before that, and so on. This splits + // on whitespace only. + // Also, it won't return words that end in a separator (if the nth word before the cursor + // ends in a separator, it returns information representing beginning-of-sentence). + // Example (when Constants.MAX_PREV_WORD_COUNT_FOR_N_GRAM is 2): + // (n = 1) "abc def|" -> abc, def + // (n = 1) "abc def |" -> abc, def + // (n = 1) "abc 'def|" -> empty, 'def + // (n = 1) "abc def. |" -> beginning-of-sentence + // (n = 1) "abc def . |" -> beginning-of-sentence + // (n = 2) "abc def|" -> beginning-of-sentence, abc + // (n = 2) "abc def |" -> beginning-of-sentence, abc + // (n = 2) "abc 'def|" -> empty. The context is different from "abc def", but we cannot + // represent this situation using NgramContext. See TODO in the method. + // TODO: The next example's result should be "abc, def". This have to be fixed before we + // retrieve the prior context of Beginning-of-Sentence. + // (n = 2) "abc def. |" -> beginning-of-sentence, abc + // (n = 2) "abc def . |" -> abc, def + // (n = 2) "abc|" -> beginning-of-sentence + // (n = 2) "abc |" -> beginning-of-sentence + // (n = 2) "abc. def|" -> beginning-of-sentence + @Nonnull + public static NgramContext getNgramContextFromNthPreviousWord(final CharSequence prev, + final SpacingAndPunctuations spacingAndPunctuations, final int n) { + if (prev == null) return NgramContext.EMPTY_PREV_WORDS_INFO; + final String[] lines = NEWLINE_REGEX.split(prev); + if (lines.length == 0) { + return new NgramContext(WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO); + } + final String[] w = SPACE_REGEX.split(lines[lines.length - 1]); + final WordInfo[] prevWordsInfo = + new WordInfo[DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM]; + Arrays.fill(prevWordsInfo, WordInfo.EMPTY_WORD_INFO); + for (int i = 0; i < prevWordsInfo.length; i++) { + final int focusedWordIndex = w.length - n - i; + // Referring to the word after the focused word. + if ((focusedWordIndex + 1) >= 0 && (focusedWordIndex + 1) < w.length) { + final String wordFollowingTheNthPrevWord = w[focusedWordIndex + 1]; + if (!wordFollowingTheNthPrevWord.isEmpty()) { + final char firstChar = wordFollowingTheNthPrevWord.charAt(0); + if (spacingAndPunctuations.isWordConnector(firstChar)) { + // The word following the focused word is starting with a word connector. + // TODO: Return meaningful context for this case. + break; + } + } + } + // If we can't find (n + i) words, the context is beginning-of-sentence. + if (focusedWordIndex < 0) { + prevWordsInfo[i] = WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO; + break; + } + + final String focusedWord = w[focusedWordIndex]; + // If the word is empty, the context is beginning-of-sentence. + final int length = focusedWord.length(); + if (length <= 0) { + prevWordsInfo[i] = WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO; + break; + } + // If the word ends in a sentence terminator, the context is beginning-of-sentence. + final char lastChar = focusedWord.charAt(length - 1); + if (spacingAndPunctuations.isSentenceTerminator(lastChar)) { + prevWordsInfo[i] = WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO; + break; + } + // If ends in a word separator or connector, the context is unclear. + // TODO: Return meaningful context for this case. + if (spacingAndPunctuations.isWordSeparator(lastChar) + || spacingAndPunctuations.isWordConnector(lastChar)) { + break; + } + prevWordsInfo[i] = new WordInfo(focusedWord); + } + return new NgramContext(prevWordsInfo); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/RecapitalizeStatus.java b/java/src/org/kelar/inputmethod/latin/utils/RecapitalizeStatus.java new file mode 100644 index 000000000..438b9871a --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/RecapitalizeStatus.java @@ -0,0 +1,221 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.Locale; + +/** + * The status of the current recapitalize process. + */ +public class RecapitalizeStatus { + public static final int NOT_A_RECAPITALIZE_MODE = -1; + public static final int CAPS_MODE_ORIGINAL_MIXED_CASE = 0; + public static final int CAPS_MODE_ALL_LOWER = 1; + public static final int CAPS_MODE_FIRST_WORD_UPPER = 2; + public static final int CAPS_MODE_ALL_UPPER = 3; + // When adding a new mode, don't forget to update the CAPS_MODE_LAST constant. + public static final int CAPS_MODE_LAST = CAPS_MODE_ALL_UPPER; + + private static final int[] ROTATION_STYLE = { + CAPS_MODE_ORIGINAL_MIXED_CASE, + CAPS_MODE_ALL_LOWER, + CAPS_MODE_FIRST_WORD_UPPER, + CAPS_MODE_ALL_UPPER + }; + + private static final int getStringMode(final String string, final int[] sortedSeparators) { + if (StringUtils.isIdenticalAfterUpcase(string)) { + return CAPS_MODE_ALL_UPPER; + } else if (StringUtils.isIdenticalAfterDowncase(string)) { + return CAPS_MODE_ALL_LOWER; + } else if (StringUtils.isIdenticalAfterCapitalizeEachWord(string, sortedSeparators)) { + return CAPS_MODE_FIRST_WORD_UPPER; + } else { + return CAPS_MODE_ORIGINAL_MIXED_CASE; + } + } + + public static String modeToString(final int recapitalizeMode) { + switch (recapitalizeMode) { + case NOT_A_RECAPITALIZE_MODE: return "undefined"; + case CAPS_MODE_ORIGINAL_MIXED_CASE: return "mixedCase"; + case CAPS_MODE_ALL_LOWER: return "allLower"; + case CAPS_MODE_FIRST_WORD_UPPER: return "firstWordUpper"; + case CAPS_MODE_ALL_UPPER: return "allUpper"; + default: return "unknown<" + recapitalizeMode + ">"; + } + } + + /** + * We store the location of the cursor and the string that was there before the recapitalize + * action was done, and the location of the cursor and the string that was there after. + */ + private int mCursorStartBefore; + private String mStringBefore; + private int mCursorStartAfter; + private int mCursorEndAfter; + private int mRotationStyleCurrentIndex; + private boolean mSkipOriginalMixedCaseMode; + private Locale mLocale; + private int[] mSortedSeparators; + private String mStringAfter; + private boolean mIsStarted; + private boolean mIsEnabled = true; + + private static final int[] EMPTY_STORTED_SEPARATORS = {}; + + public RecapitalizeStatus() { + // By default, initialize with fake values that won't match any real recapitalize. + start(-1, -1, "", Locale.getDefault(), EMPTY_STORTED_SEPARATORS); + stop(); + } + + public void start(final int cursorStart, final int cursorEnd, final String string, + final Locale locale, final int[] sortedSeparators) { + if (!mIsEnabled) { + return; + } + mCursorStartBefore = cursorStart; + mStringBefore = string; + mCursorStartAfter = cursorStart; + mCursorEndAfter = cursorEnd; + mStringAfter = string; + final int initialMode = getStringMode(mStringBefore, sortedSeparators); + mLocale = locale; + mSortedSeparators = sortedSeparators; + if (CAPS_MODE_ORIGINAL_MIXED_CASE == initialMode) { + mRotationStyleCurrentIndex = 0; + mSkipOriginalMixedCaseMode = false; + } else { + // Find the current mode in the array. + int currentMode; + for (currentMode = ROTATION_STYLE.length - 1; currentMode > 0; --currentMode) { + if (ROTATION_STYLE[currentMode] == initialMode) { + break; + } + } + mRotationStyleCurrentIndex = currentMode; + mSkipOriginalMixedCaseMode = true; + } + mIsStarted = true; + } + + public void stop() { + mIsStarted = false; + } + + public boolean isStarted() { + return mIsStarted; + } + + public void enable() { + mIsEnabled = true; + } + + public void disable() { + mIsEnabled = false; + } + + public boolean mIsEnabled() { + return mIsEnabled; + } + + public boolean isSetAt(final int cursorStart, final int cursorEnd) { + return cursorStart == mCursorStartAfter && cursorEnd == mCursorEndAfter; + } + + /** + * Rotate through the different possible capitalization modes. + */ + public void rotate() { + final String oldResult = mStringAfter; + int count = 0; // Protection against infinite loop. + do { + mRotationStyleCurrentIndex = (mRotationStyleCurrentIndex + 1) % ROTATION_STYLE.length; + if (CAPS_MODE_ORIGINAL_MIXED_CASE == ROTATION_STYLE[mRotationStyleCurrentIndex] + && mSkipOriginalMixedCaseMode) { + mRotationStyleCurrentIndex = + (mRotationStyleCurrentIndex + 1) % ROTATION_STYLE.length; + } + ++count; + switch (ROTATION_STYLE[mRotationStyleCurrentIndex]) { + case CAPS_MODE_ORIGINAL_MIXED_CASE: + mStringAfter = mStringBefore; + break; + case CAPS_MODE_ALL_LOWER: + mStringAfter = mStringBefore.toLowerCase(mLocale); + break; + case CAPS_MODE_FIRST_WORD_UPPER: + mStringAfter = StringUtils.capitalizeEachWord(mStringBefore, mSortedSeparators, + mLocale); + break; + case CAPS_MODE_ALL_UPPER: + mStringAfter = mStringBefore.toUpperCase(mLocale); + break; + default: + mStringAfter = mStringBefore; + } + } while (mStringAfter.equals(oldResult) && count < ROTATION_STYLE.length + 1); + mCursorEndAfter = mCursorStartAfter + mStringAfter.length(); + } + + /** + * Remove leading/trailing whitespace from the considered string. + */ + public void trim() { + final int len = mStringBefore.length(); + int nonWhitespaceStart = 0; + for (; nonWhitespaceStart < len; + nonWhitespaceStart = mStringBefore.offsetByCodePoints(nonWhitespaceStart, 1)) { + final int codePoint = mStringBefore.codePointAt(nonWhitespaceStart); + if (!Character.isWhitespace(codePoint)) break; + } + int nonWhitespaceEnd = len; + for (; nonWhitespaceEnd > 0; + nonWhitespaceEnd = mStringBefore.offsetByCodePoints(nonWhitespaceEnd, -1)) { + final int codePoint = mStringBefore.codePointBefore(nonWhitespaceEnd); + if (!Character.isWhitespace(codePoint)) break; + } + // If nonWhitespaceStart >= nonWhitespaceEnd, that means the selection contained only + // whitespace, so we leave it as is. + if ((0 != nonWhitespaceStart || len != nonWhitespaceEnd) + && nonWhitespaceStart < nonWhitespaceEnd) { + mCursorEndAfter = mCursorStartBefore + nonWhitespaceEnd; + mCursorStartBefore = mCursorStartAfter = mCursorStartBefore + nonWhitespaceStart; + mStringAfter = mStringBefore = + mStringBefore.substring(nonWhitespaceStart, nonWhitespaceEnd); + } + } + + public String getRecapitalizedString() { + return mStringAfter; + } + + public int getNewCursorStart() { + return mCursorStartAfter; + } + + public int getNewCursorEnd() { + return mCursorEndAfter; + } + + public int getCurrentMode() { + return ROTATION_STYLE[mRotationStyleCurrentIndex]; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ResourceUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ResourceUtils.java new file mode 100644 index 000000000..96f206a7b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ResourceUtils.java @@ -0,0 +1,319 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Insets; +import android.os.Build; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.WindowMetrics; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.regex.PatternSyntaxException; + +public final class ResourceUtils { + private static final String TAG = ResourceUtils.class.getSimpleName(); + + public static final float UNDEFINED_RATIO = -1.0f; + public static final int UNDEFINED_DIMENSION = -1; + + private ResourceUtils() { + // This utility class is not publicly instantiable. + } + + private static final HashMap<String, String> sDeviceOverrideValueMap = new HashMap<>(); + + private static final String[] BUILD_KEYS_AND_VALUES = { + "HARDWARE", Build.HARDWARE, + "MODEL", Build.MODEL, + "BRAND", Build.BRAND, + "MANUFACTURER", Build.MANUFACTURER + }; + private static final HashMap<String, String> sBuildKeyValues; + private static final String sBuildKeyValuesDebugString; + + static { + sBuildKeyValues = new HashMap<>(); + final ArrayList<String> keyValuePairs = new ArrayList<>(); + final int keyCount = BUILD_KEYS_AND_VALUES.length / 2; + for (int i = 0; i < keyCount; i++) { + final int index = i * 2; + final String key = BUILD_KEYS_AND_VALUES[index]; + final String value = BUILD_KEYS_AND_VALUES[index + 1]; + sBuildKeyValues.put(key, value); + keyValuePairs.add(key + '=' + value); + } + sBuildKeyValuesDebugString = "[" + TextUtils.join(" ", keyValuePairs) + "]"; + } + + public static String getDeviceOverrideValue(final Resources res, final int overrideResId, + final String defaultValue) { + final int orientation = res.getConfiguration().orientation; + final String key = overrideResId + "-" + orientation; + if (sDeviceOverrideValueMap.containsKey(key)) { + return sDeviceOverrideValueMap.get(key); + } + + final String[] overrideArray = res.getStringArray(overrideResId); + final String overrideValue = findConstantForKeyValuePairs(sBuildKeyValues, overrideArray); + // The overrideValue might be an empty string. + if (overrideValue != null) { + Log.i(TAG, "Find override value:" + + " resource="+ res.getResourceEntryName(overrideResId) + + " build=" + sBuildKeyValuesDebugString + + " override=" + overrideValue); + sDeviceOverrideValueMap.put(key, overrideValue); + return overrideValue; + } + + sDeviceOverrideValueMap.put(key, defaultValue); + return defaultValue; + } + + @SuppressWarnings("serial") + static class DeviceOverridePatternSyntaxError extends Exception { + public DeviceOverridePatternSyntaxError(final String message, final String expression) { + this(message, expression, null); + } + + public DeviceOverridePatternSyntaxError(final String message, final String expression, + final Throwable throwable) { + super(message + ": " + expression, throwable); + } + } + + /** + * Find the condition that fulfills specified key value pairs from an array of + * "condition,constant", and return the corresponding string constant. A condition is + * "pattern1[:pattern2...] (or an empty string for the default). A pattern is + * "key=regexp_value" string. The condition matches only if all patterns of the condition + * are true for the specified key value pairs. + * + * For example, "condition,constant" has the following format. + * - HARDWARE=mako,constantForNexus4 + * - MODEL=Nexus 4:MANUFACTURER=LGE,constantForNexus4 + * - ,defaultConstant + * + * @param keyValuePairs attributes to be used to look for a matched condition. + * @param conditionConstantArray an array of "condition,constant" elements to be searched. + * @return the constant part of the matched "condition,constant" element. Returns null if no + * condition matches. + * @see org.kelar.inputmethod.latin.utils.ResourceUtilsTests#testFindConstantForKeyValuePairsRegexp() + */ + @UsedForTesting + static String findConstantForKeyValuePairs(final HashMap<String, String> keyValuePairs, + final String[] conditionConstantArray) { + if (conditionConstantArray == null || keyValuePairs == null) { + return null; + } + String foundValue = null; + for (final String conditionConstant : conditionConstantArray) { + final int posComma = conditionConstant.indexOf(','); + if (posComma < 0) { + Log.w(TAG, "Array element has no comma: " + conditionConstant); + continue; + } + final String condition = conditionConstant.substring(0, posComma); + if (condition.isEmpty()) { + Log.w(TAG, "Array element has no condition: " + conditionConstant); + continue; + } + try { + if (fulfillsCondition(keyValuePairs, condition)) { + // Take first match + if (foundValue == null) { + foundValue = conditionConstant.substring(posComma + 1); + } + // And continue walking through all conditions. + } + } catch (final DeviceOverridePatternSyntaxError e) { + Log.w(TAG, "Syntax error, ignored", e); + } + } + return foundValue; + } + + private static boolean fulfillsCondition(final HashMap<String,String> keyValuePairs, + final String condition) throws DeviceOverridePatternSyntaxError { + final String[] patterns = condition.split(":"); + // Check all patterns in a condition are true + boolean matchedAll = true; + for (final String pattern : patterns) { + final int posEqual = pattern.indexOf('='); + if (posEqual < 0) { + throw new DeviceOverridePatternSyntaxError("Pattern has no '='", condition); + } + final String key = pattern.substring(0, posEqual); + final String value = keyValuePairs.get(key); + if (value == null) { + throw new DeviceOverridePatternSyntaxError("Unknown key", condition); + } + final String patternRegexpValue = pattern.substring(posEqual + 1); + try { + if (!value.matches(patternRegexpValue)) { + matchedAll = false; + // And continue walking through all patterns. + } + } catch (final PatternSyntaxException e) { + throw new DeviceOverridePatternSyntaxError("Syntax error", condition, e); + } + } + return matchedAll; + } + + public static int getDefaultKeyboardWidth(final Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Since Android 15’s edge-to-edge enforcement, window insets should be considered. + final WindowManager wm = context.getSystemService(WindowManager.class); + final WindowMetrics windowMetrics = wm.getCurrentWindowMetrics(); + final Insets insets = + windowMetrics + .getWindowInsets() + .getInsetsIgnoringVisibility( + WindowInsets.Type.systemBars() + | WindowInsets.Type.displayCutout()); + return windowMetrics.getBounds().width() - insets.left - insets.right; + } + final DisplayMetrics dm = context.getResources().getDisplayMetrics(); + return dm.widthPixels; + } + + public static int getKeyboardHeight(final Resources res, final SettingsValues settingsValues) { + final int defaultKeyboardHeight = getDefaultKeyboardHeight(res); + if (settingsValues.mHasKeyboardResize) { + // mKeyboardHeightScale Ranges from [.5,1.2], from xml/prefs_screen_debug.xml + return (int)(defaultKeyboardHeight * settingsValues.mKeyboardHeightScale); + } + return defaultKeyboardHeight; + } + + public static int getDefaultKeyboardHeight(final Resources res) { + final DisplayMetrics dm = res.getDisplayMetrics(); + final String keyboardHeightInDp = getDeviceOverrideValue( + res, R.array.keyboard_heights, null /* defaultValue */); + final float keyboardHeight; + if (TextUtils.isEmpty(keyboardHeightInDp)) { + keyboardHeight = res.getDimension(R.dimen.config_default_keyboard_height); + } else { + keyboardHeight = Float.parseFloat(keyboardHeightInDp) * dm.density; + } + final float maxKeyboardHeight = res.getFraction( + R.fraction.config_max_keyboard_height, dm.heightPixels, dm.heightPixels); + float minKeyboardHeight = res.getFraction( + R.fraction.config_min_keyboard_height, dm.heightPixels, dm.heightPixels); + if (minKeyboardHeight < 0.0f) { + // Specified fraction was negative, so it should be calculated against display + // width. + minKeyboardHeight = -res.getFraction( + R.fraction.config_min_keyboard_height, dm.widthPixels, dm.widthPixels); + } + // Keyboard height will not exceed maxKeyboardHeight and will not be less than + // minKeyboardHeight. + return (int)Math.max(Math.min(keyboardHeight, maxKeyboardHeight), minKeyboardHeight); + } + + public static boolean isValidFraction(final float fraction) { + return fraction >= 0.0f; + } + + // {@link Resources#getDimensionPixelSize(int)} returns at least one pixel size. + public static boolean isValidDimensionPixelSize(final int dimension) { + return dimension > 0; + } + + // {@link Resources#getDimensionPixelOffset(int)} may return zero pixel offset. + public static boolean isValidDimensionPixelOffset(final int dimension) { + return dimension >= 0; + } + + public static float getFloatFromFraction(final Resources res, final int fractionResId) { + return res.getFraction(fractionResId, 1, 1); + } + + public static float getFraction(final TypedArray a, final int index, final float defValue) { + final TypedValue value = a.peekValue(index); + if (value == null || !isFractionValue(value)) { + return defValue; + } + return a.getFraction(index, 1, 1, defValue); + } + + public static float getFraction(final TypedArray a, final int index) { + return getFraction(a, index, UNDEFINED_RATIO); + } + + public static int getDimensionPixelSize(final TypedArray a, final int index) { + final TypedValue value = a.peekValue(index); + if (value == null || !isDimensionValue(value)) { + return ResourceUtils.UNDEFINED_DIMENSION; + } + return a.getDimensionPixelSize(index, ResourceUtils.UNDEFINED_DIMENSION); + } + + public static float getDimensionOrFraction(final TypedArray a, final int index, final int base, + final float defValue) { + final TypedValue value = a.peekValue(index); + if (value == null) { + return defValue; + } + if (isFractionValue(value)) { + return a.getFraction(index, base, base, defValue); + } else if (isDimensionValue(value)) { + return a.getDimension(index, defValue); + } + return defValue; + } + + public static int getEnumValue(final TypedArray a, final int index, final int defValue) { + final TypedValue value = a.peekValue(index); + if (value == null) { + return defValue; + } + if (isIntegerValue(value)) { + return a.getInt(index, defValue); + } + return defValue; + } + + public static boolean isFractionValue(final TypedValue v) { + return v.type == TypedValue.TYPE_FRACTION; + } + + public static boolean isDimensionValue(final TypedValue v) { + return v.type == TypedValue.TYPE_DIMENSION; + } + + public static boolean isIntegerValue(final TypedValue v) { + return v.type >= TypedValue.TYPE_FIRST_INT && v.type <= TypedValue.TYPE_LAST_INT; + } + + public static boolean isStringValue(final TypedValue v) { + return v.type == TypedValue.TYPE_STRING; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/RunInLocale.java b/java/src/org/kelar/inputmethod/latin/utils/RunInLocale.java new file mode 100644 index 000000000..f890118d1 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/RunInLocale.java @@ -0,0 +1,53 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.content.res.Configuration; +import android.content.res.Resources; + +import java.util.Locale; + +public abstract class RunInLocale<T> { + private static final Object sLockForRunInLocale = new Object(); + + protected abstract T job(final Resources res); + + /** + * Execute {@link #job(Resources)} method in specified system locale exclusively. + * + * @param res the resources to use. + * @param newLocale the locale to change to. Run in system locale if null. + * @return the value returned from {@link #job(Resources)}. + */ + public T runInLocale(final Resources res, final Locale newLocale) { + synchronized (sLockForRunInLocale) { + final Configuration conf = res.getConfiguration(); + if (newLocale == null || newLocale.equals(conf.locale)) { + return job(res); + } + final Locale savedLocale = conf.locale; + try { + conf.locale = newLocale; + res.updateConfiguration(conf, null); + return job(res); + } finally { + conf.locale = savedLocale; + res.updateConfiguration(conf, null); + } + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ScriptUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ScriptUtils.java new file mode 100644 index 000000000..981bc6649 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ScriptUtils.java @@ -0,0 +1,195 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import java.util.Locale; +import java.util.TreeMap; + +/** + * A class to help with handling different writing scripts. + */ +public class ScriptUtils { + + // Used for hardware keyboards + public static final int SCRIPT_UNKNOWN = -1; + + public static final int SCRIPT_ARABIC = 0; + public static final int SCRIPT_ARMENIAN = 1; + public static final int SCRIPT_BENGALI = 2; + public static final int SCRIPT_CYRILLIC = 3; + public static final int SCRIPT_DEVANAGARI = 4; + public static final int SCRIPT_GEORGIAN = 5; + public static final int SCRIPT_GREEK = 6; + public static final int SCRIPT_HEBREW = 7; + public static final int SCRIPT_KANNADA = 8; + public static final int SCRIPT_KHMER = 9; + public static final int SCRIPT_LAO = 10; + public static final int SCRIPT_LATIN = 11; + public static final int SCRIPT_MALAYALAM = 12; + public static final int SCRIPT_MYANMAR = 13; + public static final int SCRIPT_SINHALA = 14; + public static final int SCRIPT_TAMIL = 15; + public static final int SCRIPT_TELUGU = 16; + public static final int SCRIPT_THAI = 17; + + private static final TreeMap<String, Integer> mLanguageCodeToScriptCode; + + static { + mLanguageCodeToScriptCode = new TreeMap<>(); + mLanguageCodeToScriptCode.put("", SCRIPT_LATIN); // default + mLanguageCodeToScriptCode.put("ar", SCRIPT_ARABIC); + mLanguageCodeToScriptCode.put("hy", SCRIPT_ARMENIAN); + mLanguageCodeToScriptCode.put("bn", SCRIPT_BENGALI); + mLanguageCodeToScriptCode.put("bg", SCRIPT_CYRILLIC); + mLanguageCodeToScriptCode.put("sr", SCRIPT_CYRILLIC); + mLanguageCodeToScriptCode.put("ru", SCRIPT_CYRILLIC); + mLanguageCodeToScriptCode.put("ka", SCRIPT_GEORGIAN); + mLanguageCodeToScriptCode.put("el", SCRIPT_GREEK); + mLanguageCodeToScriptCode.put("iw", SCRIPT_HEBREW); + mLanguageCodeToScriptCode.put("km", SCRIPT_KHMER); + mLanguageCodeToScriptCode.put("lo", SCRIPT_LAO); + mLanguageCodeToScriptCode.put("ml", SCRIPT_MALAYALAM); + mLanguageCodeToScriptCode.put("my", SCRIPT_MYANMAR); + mLanguageCodeToScriptCode.put("si", SCRIPT_SINHALA); + mLanguageCodeToScriptCode.put("ta", SCRIPT_TAMIL); + mLanguageCodeToScriptCode.put("te", SCRIPT_TELUGU); + mLanguageCodeToScriptCode.put("th", SCRIPT_THAI); + } + + /* + * Returns whether the code point is a letter that makes sense for the specified + * locale for this spell checker. + * The dictionaries supported by Latin IME are described in res/xml/spellchecker.xml + * and is limited to EFIGS languages and Russian. + * Hence at the moment this explicitly tests for Cyrillic characters or Latin characters + * as appropriate, and explicitly excludes CJK, Arabic and Hebrew characters. + */ + public static boolean isLetterPartOfScript(final int codePoint, final int scriptId) { + switch (scriptId) { + case SCRIPT_ARABIC: + // Arabic letters can be in any of the following blocks: + // Arabic U+0600..U+06FF + // Arabic Supplement, Thaana U+0750..U+077F, U+0780..U+07BF + // Arabic Extended-A U+08A0..U+08FF + // Arabic Presentation Forms-A U+FB50..U+FDFF + // Arabic Presentation Forms-B U+FE70..U+FEFF + return (codePoint >= 0x600 && codePoint <= 0x6FF) + || (codePoint >= 0x750 && codePoint <= 0x7BF) + || (codePoint >= 0x8A0 && codePoint <= 0x8FF) + || (codePoint >= 0xFB50 && codePoint <= 0xFDFF) + || (codePoint >= 0xFE70 && codePoint <= 0xFEFF); + case SCRIPT_ARMENIAN: + // Armenian letters are in the Armenian unicode block, U+0530..U+058F and + // Alphabetic Presentation Forms block, U+FB00..U+FB4F, but only in the Armenian part + // of that block, which is U+FB13..U+FB17. + return (codePoint >= 0x530 && codePoint <= 0x58F + || codePoint >= 0xFB13 && codePoint <= 0xFB17); + case SCRIPT_BENGALI: + // Bengali unicode block is U+0980..U+09FF + return (codePoint >= 0x980 && codePoint <= 0x9FF); + case SCRIPT_CYRILLIC: + // All Cyrillic characters are in the 400~52F block. There are some in the upper + // Unicode range, but they are archaic characters that are not used in modern + // Russian and are not used by our dictionary. + return codePoint >= 0x400 && codePoint <= 0x52F && Character.isLetter(codePoint); + case SCRIPT_DEVANAGARI: + // Devanagari unicode block is +0900..U+097F + return (codePoint >= 0x900 && codePoint <= 0x97F); + case SCRIPT_GEORGIAN: + // Georgian letters are in the Georgian unicode block, U+10A0..U+10FF, + // or Georgian supplement block, U+2D00..U+2D2F + return (codePoint >= 0x10A0 && codePoint <= 0x10FF + || codePoint >= 0x2D00 && codePoint <= 0x2D2F); + case SCRIPT_GREEK: + // Greek letters are either in the 370~3FF range (Greek & Coptic), or in the + // 1F00~1FFF range (Greek extended). Our dictionary contains both sort of characters. + // Our dictionary also contains a few words with 0xF2; it would be best to check + // if that's correct, but a web search does return results for these words so + // they are probably okay. + return (codePoint >= 0x370 && codePoint <= 0x3FF) + || (codePoint >= 0x1F00 && codePoint <= 0x1FFF) + || codePoint == 0xF2; + case SCRIPT_HEBREW: + // Hebrew letters are in the Hebrew unicode block, which spans from U+0590 to U+05FF, + // or in the Alphabetic Presentation Forms block, U+FB00..U+FB4F, but only in the + // Hebrew part of that block, which is U+FB1D..U+FB4F. + return (codePoint >= 0x590 && codePoint <= 0x5FF + || codePoint >= 0xFB1D && codePoint <= 0xFB4F); + case SCRIPT_KANNADA: + // Kannada unicode block is U+0C80..U+0CFF + return (codePoint >= 0xC80 && codePoint <= 0xCFF); + case SCRIPT_KHMER: + // Khmer letters are in unicode block U+1780..U+17FF, and the Khmer symbols block + // is U+19E0..U+19FF + return (codePoint >= 0x1780 && codePoint <= 0x17FF + || codePoint >= 0x19E0 && codePoint <= 0x19FF); + case SCRIPT_LAO: + // The Lao block is U+0E80..U+0EFF + return (codePoint >= 0xE80 && codePoint <= 0xEFF); + case SCRIPT_LATIN: + // Our supported latin script dictionaries (EFIGS) at the moment only include + // characters in the C0, C1, Latin Extended A and B, IPA extensions unicode + // blocks. As it happens, those are back-to-back in the code range 0x40 to 0x2AF, + // so the below is a very efficient way to test for it. As for the 0-0x3F, it's + // excluded from isLetter anyway. + return codePoint <= 0x2AF && Character.isLetter(codePoint); + case SCRIPT_MALAYALAM: + // Malayalam unicode block is U+0D00..U+0D7F + return (codePoint >= 0xD00 && codePoint <= 0xD7F); + case SCRIPT_MYANMAR: + // Myanmar has three unicode blocks : + // Myanmar U+1000..U+109F + // Myanmar extended-A U+AA60..U+AA7F + // Myanmar extended-B U+A9E0..U+A9FF + return (codePoint >= 0x1000 && codePoint <= 0x109F + || codePoint >= 0xAA60 && codePoint <= 0xAA7F + || codePoint >= 0xA9E0 && codePoint <= 0xA9FF); + case SCRIPT_SINHALA: + // Sinhala unicode block is U+0D80..U+0DFF + return (codePoint >= 0xD80 && codePoint <= 0xDFF); + case SCRIPT_TAMIL: + // Tamil unicode block is U+0B80..U+0BFF + return (codePoint >= 0xB80 && codePoint <= 0xBFF); + case SCRIPT_TELUGU: + // Telugu unicode block is U+0C00..U+0C7F + return (codePoint >= 0xC00 && codePoint <= 0xC7F); + case SCRIPT_THAI: + // Thai unicode block is U+0E00..U+0E7F + return (codePoint >= 0xE00 && codePoint <= 0xE7F); + case SCRIPT_UNKNOWN: + return true; + default: + // Should never come here + throw new RuntimeException("Impossible value of script: " + scriptId); + } + } + + /** + * @param locale spell checker locale + * @return internal Latin IME script code that maps to a language code + * {@see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes} + */ + public static int getScriptFromSpellCheckerLocale(final Locale locale) { + String language = locale.getLanguage(); + Integer script = mLanguageCodeToScriptCode.get(language); + if (script == null) { + // Default to Latin. + script = mLanguageCodeToScriptCode.get(""); + } + return script; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/SpannableStringUtils.java b/java/src/org/kelar/inputmethod/latin/utils/SpannableStringUtils.java new file mode 100644 index 000000000..e3c6d60bf --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/SpannableStringUtils.java @@ -0,0 +1,183 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.SpannedString; +import android.text.TextUtils; +import android.text.style.SuggestionSpan; +import android.text.style.URLSpan; + +import org.kelar.inputmethod.annotations.UsedForTesting; + +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class SpannableStringUtils { + /** + * Copies the spans from the region <code>start...end</code> in + * <code>source</code> to the region + * <code>destoff...destoff+end-start</code> in <code>dest</code>. + * Spans in <code>source</code> that begin before <code>start</code> + * or end after <code>end</code> but overlap this range are trimmed + * as if they began at <code>start</code> or ended at <code>end</code>. + * Only SuggestionSpans that don't have the SPAN_PARAGRAPH span are copied. + * + * This code is almost entirely taken from {@link TextUtils#copySpansFrom}, except for the + * kind of span that is copied. + * + * @throws IndexOutOfBoundsException if any of the copied spans + * are out of range in <code>dest</code>. + */ + public static void copyNonParagraphSuggestionSpansFrom(Spanned source, int start, int end, + Spannable dest, int destoff) { + Object[] spans = source.getSpans(start, end, SuggestionSpan.class); + + for (int i = 0; i < spans.length; i++) { + int fl = source.getSpanFlags(spans[i]); + // We don't care about the PARAGRAPH flag in LatinIME code. However, if this flag + // is set, Spannable#setSpan will throw an exception unless the span is on the edge + // of a word. But the spans have been split into two by the getText{Before,After}Cursor + // methods, so after concatenation they may end in the middle of a word. + // Since we don't use them, we can just remove them and avoid crashing. + fl &= ~Spanned.SPAN_PARAGRAPH; + + int st = source.getSpanStart(spans[i]); + int en = source.getSpanEnd(spans[i]); + + if (st < start) + st = start; + if (en > end) + en = end; + + dest.setSpan(spans[i], st - start + destoff, en - start + destoff, + fl); + } + } + + /** + * Returns a CharSequence concatenating the specified CharSequences, retaining their + * SuggestionSpans that don't have the PARAGRAPH flag, but not other spans. + * + * This code is almost entirely taken from {@link TextUtils#concat(CharSequence...)}, except + * it calls copyNonParagraphSuggestionSpansFrom instead of {@link TextUtils#copySpansFrom}. + */ + public static CharSequence concatWithNonParagraphSuggestionSpansOnly(CharSequence... text) { + if (text.length == 0) { + return ""; + } + + if (text.length == 1) { + return text[0]; + } + + boolean spanned = false; + for (int i = 0; i < text.length; i++) { + if (text[i] instanceof Spanned) { + spanned = true; + break; + } + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < text.length; i++) { + sb.append(text[i]); + } + + if (!spanned) { + return sb.toString(); + } + + SpannableString ss = new SpannableString(sb); + int off = 0; + for (int i = 0; i < text.length; i++) { + int len = text[i].length(); + + if (text[i] instanceof Spanned) { + copyNonParagraphSuggestionSpansFrom((Spanned) text[i], 0, len, ss, off); + } + + off += len; + } + + return new SpannedString(ss); + } + + public static boolean hasUrlSpans(final CharSequence text, + final int startIndex, final int endIndex) { + if (!(text instanceof Spanned)) { + return false; // Not spanned, so no link + } + final Spanned spanned = (Spanned)text; + // getSpans(x, y) does not return spans that start on x or end on y. x-1, y+1 does the + // trick, and works in all cases even if startIndex <= 0 or endIndex >= text.length(). + final URLSpan[] spans = spanned.getSpans(startIndex - 1, endIndex + 1, URLSpan.class); + return null != spans && spans.length > 0; + } + + /** + * Splits the given {@code charSequence} with at occurrences of the given {@code regex}. + * <p> + * This is equivalent to + * {@code charSequence.toString().split(regex, preserveTrailingEmptySegments ? -1 : 0)} + * except that the spans are preserved in the result array. + * </p> + * @param charSequence the character sequence to be split. + * @param regex the regex pattern to be used as the separator. + * @param preserveTrailingEmptySegments {@code true} to preserve the trailing empty + * segments. Otherwise, trailing empty segments will be removed before being returned. + * @return the array which contains the result. All the spans in the <code>charSequence</code> + * is preserved. + */ + @UsedForTesting + public static CharSequence[] split(final CharSequence charSequence, final String regex, + final boolean preserveTrailingEmptySegments) { + // A short-cut for non-spanned strings. + if (!(charSequence instanceof Spanned)) { + // -1 means that trailing empty segments will be preserved. + return charSequence.toString().split(regex, preserveTrailingEmptySegments ? -1 : 0); + } + + // Hereafter, emulate String.split for CharSequence. + final ArrayList<CharSequence> sequences = new ArrayList<>(); + final Matcher matcher = Pattern.compile(regex).matcher(charSequence); + int nextStart = 0; + boolean matched = false; + while (matcher.find()) { + sequences.add(charSequence.subSequence(nextStart, matcher.start())); + nextStart = matcher.end(); + matched = true; + } + if (!matched) { + // never matched. preserveTrailingEmptySegments is ignored in this case. + return new CharSequence[] { charSequence }; + } + sequences.add(charSequence.subSequence(nextStart, charSequence.length())); + if (!preserveTrailingEmptySegments) { + for (int i = sequences.size() - 1; i >= 0; --i) { + if (!TextUtils.isEmpty(sequences.get(i))) { + break; + } + sequences.remove(i); + } + } + return sequences.toArray(new CharSequence[sequences.size()]); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/StatsUtils.java b/java/src/org/kelar/inputmethod/latin/utils/StatsUtils.java new file mode 100644 index 000000000..f690eae3e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/StatsUtils.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.latin.DictionaryFacilitator; +import org.kelar.inputmethod.latin.RichInputMethodManager; +import org.kelar.inputmethod.latin.SuggestedWords; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +@SuppressWarnings("unused") +public final class StatsUtils { + + private StatsUtils() { + // Intentional empty constructor. + } + + public static void onCreate(final SettingsValues settingsValues, + RichInputMethodManager richImm) { + } + + public static void onPickSuggestionManually(final SuggestedWords suggestedWords, + final SuggestedWords.SuggestedWordInfo suggestionInfo, + final DictionaryFacilitator dictionaryFacilitator) { + } + + public static void onBackspaceWordDelete(int wordLength) { + } + + public static void onBackspacePressed(int lengthToDelete) { + } + + public static void onBackspaceSelectedText(int selectedTextLength) { + } + + public static void onDeleteMultiCharInput(int multiCharLength) { + } + + public static void onRevertAutoCorrect() { + } + + public static void onRevertDoubleSpacePeriod() { + } + + public static void onRevertSwapPunctuation() { + } + + public static void onFinishInputView() { + } + + public static void onCreateInputView() { + } + + public static void onStartInputView(int inputType, int displayOrientation, boolean restarting) { + } + + public static void onAutoCorrection(final String typedWord, final String autoCorrectionWord, + final boolean isBatchInput, final DictionaryFacilitator dictionaryFacilitator, + final String prevWordsContext) { + } + + public static void onWordCommitUserTyped(final String commitWord, final boolean isBatchMode) { + } + + public static void onWordCommitAutoCorrect(final String commitWord, final boolean isBatchMode) { + } + + public static void onWordCommitSuggestionPickedManually( + final String commitWord, final boolean isBatchMode) { + } + + public static void onDoubleSpacePeriod() { + } + + public static void onLoadSettings(SettingsValues settingsValues) { + } + + public static void onInvalidWordIdentification(final String invalidWord) { + } + + public static void onSubtypeChanged(final InputMethodSubtype oldSubtype, + final InputMethodSubtype newSubtype) { + } + + public static void onSettingsActivity(final String entryPoint) { + } + + public static void onInputConnectionLaggy(final int operation, final long duration) { + } + + public static void onDecoderLaggy(final int operation, final long duration) { + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/StatsUtilsManager.java b/java/src/org/kelar/inputmethod/latin/utils/StatsUtilsManager.java new file mode 100644 index 000000000..5c86f020e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/StatsUtilsManager.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; + +import org.kelar.inputmethod.latin.DictionaryFacilitator; +import org.kelar.inputmethod.latin.settings.SettingsValues; + +@SuppressWarnings("unused") +public class StatsUtilsManager { + + private static final StatsUtilsManager sInstance = new StatsUtilsManager(); + private static StatsUtilsManager sTestInstance = null; + + /** + * @return the singleton instance of {@link StatsUtilsManager}. + */ + public static StatsUtilsManager getInstance() { + return sTestInstance != null ? sTestInstance : sInstance; + } + + public static void setTestInstance(final StatsUtilsManager testInstance) { + sTestInstance = testInstance; + } + + public void onCreate(final Context context, final DictionaryFacilitator dictionaryFacilitator) { + } + + public void onLoadSettings(final Context context, final SettingsValues settingsValues) { + } + + public void onStartInputView() { + } + + public void onFinishInputView() { + } + + public void onDestroy(final Context context) { + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/SubtypeLocaleUtils.java b/java/src/org/kelar/inputmethod/latin/utils/SubtypeLocaleUtils.java new file mode 100644 index 000000000..2be7ca5ba --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/SubtypeLocaleUtils.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.COMBINING_RULES; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET; +import static org.kelar.inputmethod.latin.common.Constants.Subtype.ExtraValue.UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Build; +import android.util.Log; +import android.view.inputmethod.InputMethodSubtype; + +import org.kelar.inputmethod.latin.R; +import org.kelar.inputmethod.latin.common.LocaleUtils; +import org.kelar.inputmethod.latin.common.StringUtils; + +import java.util.HashMap; +import java.util.Locale; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A helper class to deal with subtype locales. + */ +// TODO: consolidate this into RichInputMethodSubtype +public final class SubtypeLocaleUtils { + static final String TAG = SubtypeLocaleUtils.class.getSimpleName(); + + // This reference class {@link R} must be located in the same package as LatinIME.java. + private static final String RESOURCE_PACKAGE_NAME = R.class.getPackage().getName(); + + // Special language code to represent "no language". + public static final String NO_LANGUAGE = "zz"; + public static final String QWERTY = "qwerty"; + public static final String EMOJI = "emoji"; + public static final int UNKNOWN_KEYBOARD_LAYOUT = R.string.subtype_generic; + + private static volatile boolean sInitialized = false; + private static final Object sInitializeLock = new Object(); + private static Resources sResources; + // Keyboard layout to its display name map. + private static final HashMap<String, String> sKeyboardLayoutToDisplayNameMap = new HashMap<>(); + // Keyboard layout to subtype name resource id map. + private static final HashMap<String, Integer> sKeyboardLayoutToNameIdsMap = new HashMap<>(); + // Exceptional locale whose name should be displayed in Locale.ROOT. + private static final HashMap<String, Integer> sExceptionalLocaleDisplayedInRootLocale = + new HashMap<>(); + // Exceptional locale to subtype name resource id map. + private static final HashMap<String, Integer> sExceptionalLocaleToNameIdsMap = new HashMap<>(); + // Exceptional locale to subtype name with layout resource id map. + private static final HashMap<String, Integer> sExceptionalLocaleToWithLayoutNameIdsMap = + new HashMap<>(); + private static final String SUBTYPE_NAME_RESOURCE_PREFIX = + "string/subtype_"; + private static final String SUBTYPE_NAME_RESOURCE_GENERIC_PREFIX = + "string/subtype_generic_"; + private static final String SUBTYPE_NAME_RESOURCE_WITH_LAYOUT_PREFIX = + "string/subtype_with_layout_"; + private static final String SUBTYPE_NAME_RESOURCE_NO_LANGUAGE_PREFIX = + "string/subtype_no_language_"; + private static final String SUBTYPE_NAME_RESOURCE_IN_ROOT_LOCALE_PREFIX = + "string/subtype_in_root_locale_"; + // Keyboard layout set name for the subtypes that don't have a keyboardLayoutSet extra value. + // This is for compatibility to keep the same subtype ids as pre-JellyBean. + private static final HashMap<String, String> sLocaleAndExtraValueToKeyboardLayoutSetMap = + new HashMap<>(); + + private SubtypeLocaleUtils() { + // Intentional empty constructor for utility class. + } + + // Note that this initialization method can be called multiple times. + public static void init(final Context context) { + synchronized (sInitializeLock) { + if (sInitialized == false) { + initLocked(context); + sInitialized = true; + } + } + } + + private static void initLocked(final Context context) { + final Resources res = context.getResources(); + sResources = res; + + final String[] predefinedLayoutSet = res.getStringArray(R.array.predefined_layouts); + final String[] layoutDisplayNames = res.getStringArray( + R.array.predefined_layout_display_names); + for (int i = 0; i < predefinedLayoutSet.length; i++) { + final String layoutName = predefinedLayoutSet[i]; + sKeyboardLayoutToDisplayNameMap.put(layoutName, layoutDisplayNames[i]); + final String resourceName = SUBTYPE_NAME_RESOURCE_GENERIC_PREFIX + layoutName; + final int resId = res.getIdentifier(resourceName, null, RESOURCE_PACKAGE_NAME); + sKeyboardLayoutToNameIdsMap.put(layoutName, resId); + // Register subtype name resource id of "No language" with key "zz_<layout>" + final String noLanguageResName = SUBTYPE_NAME_RESOURCE_NO_LANGUAGE_PREFIX + layoutName; + final int noLanguageResId = res.getIdentifier( + noLanguageResName, null, RESOURCE_PACKAGE_NAME); + final String key = getNoLanguageLayoutKey(layoutName); + sKeyboardLayoutToNameIdsMap.put(key, noLanguageResId); + } + + final String[] exceptionalLocaleInRootLocale = res.getStringArray( + R.array.subtype_locale_displayed_in_root_locale); + for (int i = 0; i < exceptionalLocaleInRootLocale.length; i++) { + final String localeString = exceptionalLocaleInRootLocale[i]; + final String resourceName = SUBTYPE_NAME_RESOURCE_IN_ROOT_LOCALE_PREFIX + localeString; + final int resId = res.getIdentifier(resourceName, null, RESOURCE_PACKAGE_NAME); + sExceptionalLocaleDisplayedInRootLocale.put(localeString, resId); + } + + final String[] exceptionalLocales = res.getStringArray( + R.array.subtype_locale_exception_keys); + for (int i = 0; i < exceptionalLocales.length; i++) { + final String localeString = exceptionalLocales[i]; + final String resourceName = SUBTYPE_NAME_RESOURCE_PREFIX + localeString; + final int resId = res.getIdentifier(resourceName, null, RESOURCE_PACKAGE_NAME); + sExceptionalLocaleToNameIdsMap.put(localeString, resId); + final String resourceNameWithLayout = + SUBTYPE_NAME_RESOURCE_WITH_LAYOUT_PREFIX + localeString; + final int resIdWithLayout = res.getIdentifier( + resourceNameWithLayout, null, RESOURCE_PACKAGE_NAME); + sExceptionalLocaleToWithLayoutNameIdsMap.put(localeString, resIdWithLayout); + } + + final String[] keyboardLayoutSetMap = res.getStringArray( + R.array.locale_and_extra_value_to_keyboard_layout_set_map); + for (int i = 0; i + 1 < keyboardLayoutSetMap.length; i += 2) { + final String key = keyboardLayoutSetMap[i]; + final String keyboardLayoutSet = keyboardLayoutSetMap[i + 1]; + sLocaleAndExtraValueToKeyboardLayoutSetMap.put(key, keyboardLayoutSet); + } + } + + public static boolean isExceptionalLocale(final String localeString) { + return sExceptionalLocaleToNameIdsMap.containsKey(localeString); + } + + private static final String getNoLanguageLayoutKey(final String keyboardLayoutName) { + return NO_LANGUAGE + "_" + keyboardLayoutName; + } + + public static int getSubtypeNameId(final String localeString, final String keyboardLayoutName) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN + && isExceptionalLocale(localeString)) { + return sExceptionalLocaleToWithLayoutNameIdsMap.get(localeString); + } + final String key = NO_LANGUAGE.equals(localeString) + ? getNoLanguageLayoutKey(keyboardLayoutName) + : keyboardLayoutName; + final Integer nameId = sKeyboardLayoutToNameIdsMap.get(key); + return nameId == null ? UNKNOWN_KEYBOARD_LAYOUT : nameId; + } + + @Nonnull + public static Locale getDisplayLocaleOfSubtypeLocale(@Nonnull final String localeString) { + if (NO_LANGUAGE.equals(localeString)) { + return sResources.getConfiguration().locale; + } + if (sExceptionalLocaleDisplayedInRootLocale.containsKey(localeString)) { + return Locale.ROOT; + } + return LocaleUtils.constructLocaleFromString(localeString); + } + + public static String getSubtypeLocaleDisplayNameInSystemLocale( + @Nonnull final String localeString) { + final Locale displayLocale = sResources.getConfiguration().locale; + return getSubtypeLocaleDisplayNameInternal(localeString, displayLocale); + } + + @Nonnull + public static String getSubtypeLocaleDisplayName(@Nonnull final String localeString) { + final Locale displayLocale = getDisplayLocaleOfSubtypeLocale(localeString); + return getSubtypeLocaleDisplayNameInternal(localeString, displayLocale); + } + + @Nonnull + public static String getSubtypeLanguageDisplayName(@Nonnull final String localeString) { + final Locale displayLocale = getDisplayLocaleOfSubtypeLocale(localeString); + final String languageString; + if (sExceptionalLocaleDisplayedInRootLocale.containsKey(localeString)) { + languageString = localeString; + } else { + languageString = LocaleUtils.constructLocaleFromString(localeString).getLanguage(); + } + return getSubtypeLocaleDisplayNameInternal(languageString, displayLocale); + } + + @Nonnull + private static String getSubtypeLocaleDisplayNameInternal(@Nonnull final String localeString, + @Nonnull final Locale displayLocale) { + if (NO_LANGUAGE.equals(localeString)) { + // No language subtype should be displayed in system locale. + return sResources.getString(R.string.subtype_no_language); + } + final Integer exceptionalNameResId; + if (displayLocale.equals(Locale.ROOT) + && sExceptionalLocaleDisplayedInRootLocale.containsKey(localeString)) { + exceptionalNameResId = sExceptionalLocaleDisplayedInRootLocale.get(localeString); + } else if (sExceptionalLocaleToNameIdsMap.containsKey(localeString)) { + exceptionalNameResId = sExceptionalLocaleToNameIdsMap.get(localeString); + } else { + exceptionalNameResId = null; + } + + final String displayName; + if (exceptionalNameResId != null) { + final RunInLocale<String> getExceptionalName = new RunInLocale<String>() { + @Override + protected String job(final Resources res) { + return res.getString(exceptionalNameResId); + } + }; + displayName = getExceptionalName.runInLocale(sResources, displayLocale); + } else { + displayName = LocaleUtils.constructLocaleFromString(localeString) + .getDisplayName(displayLocale); + } + return StringUtils.capitalizeFirstCodePoint(displayName, displayLocale); + } + + // InputMethodSubtype's display name in its locale. + // isAdditionalSubtype (T=true, F=false) + // locale layout | display name + // ------ ------- - ---------------------- + // en_US qwerty F English (US) exception + // en_GB qwerty F English (UK) exception + // es_US spanish F Español (EE.UU.) exception + // fr azerty F Français + // fr_CA qwerty F Français (Canada) + // fr_CH swiss F Français (Suisse) + // de qwertz F Deutsch + // de_CH swiss T Deutsch (Schweiz) + // zz qwerty F Alphabet (QWERTY) in system locale + // fr qwertz T Français (QWERTZ) + // de qwerty T Deutsch (QWERTY) + // en_US azerty T English (US) (AZERTY) exception + // zz azerty T Alphabet (AZERTY) in system locale + + @Nonnull + private static String getReplacementString(@Nonnull final InputMethodSubtype subtype, + @Nonnull final Locale displayLocale) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN + && subtype.containsExtraValueKey(UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME)) { + return subtype.getExtraValueOf(UNTRANSLATABLE_STRING_IN_SUBTYPE_NAME); + } + return getSubtypeLocaleDisplayNameInternal(subtype.getLocale(), displayLocale); + } + + @Nonnull + public static String getSubtypeDisplayNameInSystemLocale( + @Nonnull final InputMethodSubtype subtype) { + final Locale displayLocale = sResources.getConfiguration().locale; + return getSubtypeDisplayNameInternal(subtype, displayLocale); + } + + @Nonnull + public static String getSubtypeNameForLogging(@Nullable final InputMethodSubtype subtype) { + if (subtype == null) { + return "<null subtype>"; + } + return getSubtypeLocale(subtype) + "/" + getKeyboardLayoutSetName(subtype); + } + + @Nonnull + private static String getSubtypeDisplayNameInternal(@Nonnull final InputMethodSubtype subtype, + @Nonnull final Locale displayLocale) { + final String replacementString = getReplacementString(subtype, displayLocale); + // TODO: rework this for multi-lingual subtypes + final int nameResId = subtype.getNameResId(); + final RunInLocale<String> getSubtypeName = new RunInLocale<String>() { + @Override + protected String job(final Resources res) { + try { + return res.getString(nameResId, replacementString); + } catch (Resources.NotFoundException e) { + // TODO: Remove this catch when InputMethodManager.getCurrentInputMethodSubtype + // is fixed. + Log.w(TAG, "Unknown subtype: mode=" + subtype.getMode() + + " nameResId=" + subtype.getNameResId() + + " locale=" + subtype.getLocale() + + " extra=" + subtype.getExtraValue() + + "\n" + DebugLogUtils.getStackTrace()); + return ""; + } + } + }; + return StringUtils.capitalizeFirstCodePoint( + getSubtypeName.runInLocale(sResources, displayLocale), displayLocale); + } + + @Nonnull + public static Locale getSubtypeLocale(@Nonnull final InputMethodSubtype subtype) { + final String localeString = subtype.getLocale(); + return LocaleUtils.constructLocaleFromString(localeString); + } + + @Nonnull + public static String getKeyboardLayoutSetDisplayName( + @Nonnull final InputMethodSubtype subtype) { + final String layoutName = getKeyboardLayoutSetName(subtype); + return getKeyboardLayoutSetDisplayName(layoutName); + } + + @Nonnull + public static String getKeyboardLayoutSetDisplayName(@Nonnull final String layoutName) { + return sKeyboardLayoutToDisplayNameMap.get(layoutName); + } + + @Nonnull + public static String getKeyboardLayoutSetName(final InputMethodSubtype subtype) { + String keyboardLayoutSet = subtype.getExtraValueOf(KEYBOARD_LAYOUT_SET); + if (keyboardLayoutSet == null) { + // This subtype doesn't have a keyboardLayoutSet extra value, so lookup its keyboard + // layout set in sLocaleAndExtraValueToKeyboardLayoutSetMap to keep it compatible with + // pre-JellyBean. + final String key = subtype.getLocale() + ":" + subtype.getExtraValue(); + keyboardLayoutSet = sLocaleAndExtraValueToKeyboardLayoutSetMap.get(key); + } + // TODO: Remove this null check when InputMethodManager.getCurrentInputMethodSubtype is + // fixed. + if (keyboardLayoutSet == null) { + android.util.Log.w(TAG, "KeyboardLayoutSet not found, use QWERTY: " + + "locale=" + subtype.getLocale() + " extraValue=" + subtype.getExtraValue()); + return QWERTY; + } + return keyboardLayoutSet; + } + + public static String getCombiningRulesExtraValue(final InputMethodSubtype subtype) { + return subtype.getExtraValueOf(COMBINING_RULES); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/SuggestionResults.java b/java/src/org/kelar/inputmethod/latin/utils/SuggestionResults.java new file mode 100644 index 000000000..0cd484704 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/SuggestionResults.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import org.kelar.inputmethod.latin.SuggestedWords.SuggestedWordInfo; +import org.kelar.inputmethod.latin.define.ProductionFlags; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.TreeSet; + +/** + * A TreeSet of SuggestedWordInfo that is bounded in size and throws everything that's smaller + * than its limit + */ +public final class SuggestionResults extends TreeSet<SuggestedWordInfo> { + public final ArrayList<SuggestedWordInfo> mRawSuggestions; + // TODO: Instead of a boolean , we may want to include the context of this suggestion results, + // such as {@link NgramContext}. + public final boolean mIsBeginningOfSentence; + public final boolean mFirstSuggestionExceedsConfidenceThreshold; + private final int mCapacity; + + public SuggestionResults(final int capacity, final boolean isBeginningOfSentence, + final boolean firstSuggestionExceedsConfidenceThreshold) { + this(sSuggestedWordInfoComparator, capacity, isBeginningOfSentence, + firstSuggestionExceedsConfidenceThreshold); + } + + private SuggestionResults(final Comparator<SuggestedWordInfo> comparator, final int capacity, + final boolean isBeginningOfSentence, + final boolean firstSuggestionExceedsConfidenceThreshold) { + super(comparator); + mCapacity = capacity; + if (ProductionFlags.INCLUDE_RAW_SUGGESTIONS) { + mRawSuggestions = new ArrayList<>(); + } else { + mRawSuggestions = null; + } + mIsBeginningOfSentence = isBeginningOfSentence; + mFirstSuggestionExceedsConfidenceThreshold = firstSuggestionExceedsConfidenceThreshold; + } + + @Override + public boolean add(final SuggestedWordInfo e) { + if (size() < mCapacity) return super.add(e); + if (comparator().compare(e, last()) > 0) return false; + super.add(e); + pollLast(); // removes the last element + return true; + } + + @Override + public boolean addAll(final Collection<? extends SuggestedWordInfo> e) { + if (null == e) return false; + return super.addAll(e); + } + + static final class SuggestedWordInfoComparator implements Comparator<SuggestedWordInfo> { + // This comparator ranks the word info with the higher frequency first. That's because + // that's the order we want our elements in. + @Override + public int compare(final SuggestedWordInfo o1, final SuggestedWordInfo o2) { + if (o1.mScore > o2.mScore) return -1; + if (o1.mScore < o2.mScore) return 1; + if (o1.mCodePointCount < o2.mCodePointCount) return -1; + if (o1.mCodePointCount > o2.mCodePointCount) return 1; + return o1.mWord.compareTo(o2.mWord); + } + } + + private static final SuggestedWordInfoComparator sSuggestedWordInfoComparator = + new SuggestedWordInfoComparator(); +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/TargetPackageInfoGetterTask.java b/java/src/org/kelar/inputmethod/latin/utils/TargetPackageInfoGetterTask.java new file mode 100644 index 000000000..1d0a3e942 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/TargetPackageInfoGetterTask.java @@ -0,0 +1,67 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.AsyncTask; +import android.util.LruCache; + +import org.kelar.inputmethod.compat.AppWorkaroundsUtils; + +public final class TargetPackageInfoGetterTask extends + AsyncTask<String, Void, PackageInfo> { + private static final int MAX_CACHE_ENTRIES = 64; // arbitrary + private static final LruCache<String, PackageInfo> sCache = new LruCache<>(MAX_CACHE_ENTRIES); + + public static PackageInfo getCachedPackageInfo(final String packageName) { + if (null == packageName) return null; + return sCache.get(packageName); + } + + public static void removeCachedPackageInfo(final String packageName) { + sCache.remove(packageName); + } + + private Context mContext; + private final AsyncResultHolder<AppWorkaroundsUtils> mResult; + + public TargetPackageInfoGetterTask(final Context context, + final AsyncResultHolder<AppWorkaroundsUtils> result) { + mContext = context; + mResult = result; + } + + @Override + protected PackageInfo doInBackground(final String... packageName) { + final PackageManager pm = mContext.getPackageManager(); + mContext = null; // Bazooka-powered anti-leak device + try { + final PackageInfo packageInfo = pm.getPackageInfo(packageName[0], 0 /* flags */); + sCache.put(packageName[0], packageInfo); + return packageInfo; + } catch (android.content.pm.PackageManager.NameNotFoundException e) { + return null; + } + } + + @Override + protected void onPostExecute(final PackageInfo info) { + mResult.set(new AppWorkaroundsUtils(info)); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/TextRange.java b/java/src/org/kelar/inputmethod/latin/utils/TextRange.java new file mode 100644 index 000000000..2b0397d8e --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/TextRange.java @@ -0,0 +1,122 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.text.Spanned; +import android.text.style.SuggestionSpan; + +import java.util.Arrays; + +/** + * Represents a range of text, relative to the current cursor position. + */ +public final class TextRange { + private final CharSequence mTextAtCursor; + private final int mWordAtCursorStartIndex; + private final int mWordAtCursorEndIndex; + private final int mCursorIndex; + + public final CharSequence mWord; + public final boolean mHasUrlSpans; + + public int getNumberOfCharsInWordBeforeCursor() { + return mCursorIndex - mWordAtCursorStartIndex; + } + + public int getNumberOfCharsInWordAfterCursor() { + return mWordAtCursorEndIndex - mCursorIndex; + } + + public int length() { + return mWord.length(); + } + + /** + * Gets the suggestion spans that are put squarely on the word, with the exact start + * and end of the span matching the boundaries of the word. + * @return the list of spans. + */ + public SuggestionSpan[] getSuggestionSpansAtWord() { + if (!(mTextAtCursor instanceof Spanned && mWord instanceof Spanned)) { + return new SuggestionSpan[0]; + } + final Spanned text = (Spanned)mTextAtCursor; + // Note: it's fine to pass indices negative or greater than the length of the string + // to the #getSpans() method. The reason we need to get from -1 to +1 is that, the + // spans were cut at the cursor position, and #getSpans(start, end) does not return + // spans that end at `start' or begin at `end'. Consider the following case: + // this| is (The | symbolizes the cursor position + // ---- --- + // In this case, the cursor is in position 4, so the 0~7 span has been split into + // a 0~4 part and a 4~7 part. + // If we called #getSpans(0, 4) in this case, we would only get the part from 0 to 4 + // of the span, and not the part from 4 to 7, so we would not realize the span actually + // extends from 0 to 7. But if we call #getSpans(-1, 5) we'll get both the 0~4 and + // the 4~7 spans and we can merge them accordingly. + // Any span starting more than 1 char away from the word boundaries in any direction + // does not touch the word, so we don't need to consider it. That's why requesting + // -1 ~ +1 is enough. + // Of course this is only relevant if the cursor is at one end of the word. If it's + // in the middle, the -1 and +1 are not necessary, but they are harmless. + final SuggestionSpan[] spans = text.getSpans(mWordAtCursorStartIndex - 1, + mWordAtCursorEndIndex + 1, SuggestionSpan.class); + int readIndex = 0; + int writeIndex = 0; + for (; readIndex < spans.length; ++readIndex) { + final SuggestionSpan span = spans[readIndex]; + // The span may be null, as we null them when we find duplicates. Cf a few lines + // down. + if (null == span) continue; + // Tentative span start and end. This may be modified later if we realize the + // same span is also applied to other parts of the string. + int spanStart = text.getSpanStart(span); + int spanEnd = text.getSpanEnd(span); + for (int i = readIndex + 1; i < spans.length; ++i) { + if (span.equals(spans[i])) { + // We found the same span somewhere else. Read the new extent of this + // span, and adjust our values accordingly. + spanStart = Math.min(spanStart, text.getSpanStart(spans[i])); + spanEnd = Math.max(spanEnd, text.getSpanEnd(spans[i])); + // ...and mark the span as processed. + spans[i] = null; + } + } + if (spanStart == mWordAtCursorStartIndex && spanEnd == mWordAtCursorEndIndex) { + // If the span does not start and stop here, ignore it. It probably extends + // past the start or end of the word, as happens in missing space correction + // or EasyEditSpans put by voice input. + spans[writeIndex++] = spans[readIndex]; + } + } + return writeIndex == readIndex ? spans : Arrays.copyOfRange(spans, 0, writeIndex); + } + + public TextRange(final CharSequence textAtCursor, final int wordAtCursorStartIndex, + final int wordAtCursorEndIndex, final int cursorIndex, final boolean hasUrlSpans) { + if (wordAtCursorStartIndex < 0 || cursorIndex < wordAtCursorStartIndex + || cursorIndex > wordAtCursorEndIndex + || wordAtCursorEndIndex > textAtCursor.length()) { + throw new IndexOutOfBoundsException(); + } + mTextAtCursor = textAtCursor; + mWordAtCursorStartIndex = wordAtCursorStartIndex; + mWordAtCursorEndIndex = wordAtCursorEndIndex; + mCursorIndex = cursorIndex; + mHasUrlSpans = hasUrlSpans; + mWord = mTextAtCursor.subSequence(mWordAtCursorStartIndex, mWordAtCursorEndIndex); + } +}
\ No newline at end of file diff --git a/java/src/org/kelar/inputmethod/latin/utils/TypefaceUtils.java b/java/src/org/kelar/inputmethod/latin/utils/TypefaceUtils.java new file mode 100644 index 000000000..5e0a985ed --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/TypefaceUtils.java @@ -0,0 +1,108 @@ +/* + * 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 org.kelar.inputmethod.latin.utils; + +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.util.SparseArray; + +public final class TypefaceUtils { + private static final char[] KEY_LABEL_REFERENCE_CHAR = { 'M' }; + private static final char[] KEY_NUMERIC_HINT_LABEL_REFERENCE_CHAR = { '8' }; + + private TypefaceUtils() { + // This utility class is not publicly instantiable. + } + + // This sparse array caches key label text height in pixel indexed by key label text size. + private static final SparseArray<Float> sTextHeightCache = new SparseArray<>(); + // Working variable for the following method. + private static final Rect sTextHeightBounds = new Rect(); + + private static float getCharHeight(final char[] referenceChar, final Paint paint) { + final int key = getCharGeometryCacheKey(referenceChar[0], paint); + synchronized (sTextHeightCache) { + final Float cachedValue = sTextHeightCache.get(key); + if (cachedValue != null) { + return cachedValue; + } + + paint.getTextBounds(referenceChar, 0, 1, sTextHeightBounds); + final float height = sTextHeightBounds.height(); + sTextHeightCache.put(key, height); + return height; + } + } + + // This sparse array caches key label text width in pixel indexed by key label text size. + private static final SparseArray<Float> sTextWidthCache = new SparseArray<>(); + // Working variable for the following method. + private static final Rect sTextWidthBounds = new Rect(); + + private static float getCharWidth(final char[] referenceChar, final Paint paint) { + final int key = getCharGeometryCacheKey(referenceChar[0], paint); + synchronized (sTextWidthCache) { + final Float cachedValue = sTextWidthCache.get(key); + if (cachedValue != null) { + return cachedValue; + } + + paint.getTextBounds(referenceChar, 0, 1, sTextWidthBounds); + final float width = sTextWidthBounds.width(); + sTextWidthCache.put(key, width); + return width; + } + } + + private static int getCharGeometryCacheKey(final char referenceChar, final Paint paint) { + final int labelSize = (int)paint.getTextSize(); + final Typeface face = paint.getTypeface(); + final int codePointOffset = referenceChar << 15; + if (face == Typeface.DEFAULT) { + return codePointOffset + labelSize; + } else if (face == Typeface.DEFAULT_BOLD) { + return codePointOffset + labelSize + 0x1000; + } else if (face == Typeface.MONOSPACE) { + return codePointOffset + labelSize + 0x2000; + } else { + return codePointOffset + labelSize; + } + } + + public static float getReferenceCharHeight(final Paint paint) { + return getCharHeight(KEY_LABEL_REFERENCE_CHAR, paint); + } + + public static float getReferenceCharWidth(final Paint paint) { + return getCharWidth(KEY_LABEL_REFERENCE_CHAR, paint); + } + + public static float getReferenceDigitWidth(final Paint paint) { + return getCharWidth(KEY_NUMERIC_HINT_LABEL_REFERENCE_CHAR, paint); + } + + // Working variable for the following method. + private static final Rect sStringWidthBounds = new Rect(); + + public static float getStringWidth(final String string, final Paint paint) { + synchronized (sStringWidthBounds) { + paint.getTextBounds(string, 0, string.length(), sStringWidthBounds); + return sStringWidthBounds.width(); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/UncachedInputMethodManagerUtils.java b/java/src/org/kelar/inputmethod/latin/utils/UncachedInputMethodManagerUtils.java new file mode 100644 index 000000000..fd29bf9e3 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/UncachedInputMethodManagerUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.content.Context; +import android.provider.Settings; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; + +/* + * A utility class for {@link InputMethodManager}. Unlike {@link RichInputMethodManager}, this + * class provides synchronous, non-cached access to {@link InputMethodManager}. The setup activity + * is a good example to use this class because {@link InputMethodManagerService} may not be aware of + * this IME immediately after this IME is installed. + */ +public final class UncachedInputMethodManagerUtils { + /** + * Check if the IME specified by the context is enabled. + * CAVEAT: This may cause a round trip IPC. + * + * @param context package context of the IME to be checked. + * @param imm the {@link InputMethodManager}. + * @return true if this IME is enabled. + */ + public static boolean isThisImeEnabled(final Context context, + final InputMethodManager imm) { + final String packageName = context.getPackageName(); + for (final InputMethodInfo imi : imm.getEnabledInputMethodList()) { + if (packageName.equals(imi.getPackageName())) { + return true; + } + } + return false; + } + + /** + * Check if the IME specified by the context is the current IME. + * CAVEAT: This may cause a round trip IPC. + * + * @param context package context of the IME to be checked. + * @param imm the {@link InputMethodManager}. + * @return true if this IME is the current IME. + */ + public static boolean isThisImeCurrent(final Context context, + final InputMethodManager imm) { + final InputMethodInfo imi = getInputMethodInfoOf(context.getPackageName(), imm); + final String currentImeId = Settings.Secure.getString( + context.getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD); + return imi != null && imi.getId().equals(currentImeId); + } + + /** + * Get {@link InputMethodInfo} of the IME specified by the package name. + * CAVEAT: This may cause a round trip IPC. + * + * @param packageName package name of the IME. + * @param imm the {@link InputMethodManager}. + * @return the {@link InputMethodInfo} of the IME specified by the <code>packageName</code>, + * or null if not found. + */ + public static InputMethodInfo getInputMethodInfoOf(final String packageName, + final InputMethodManager imm) { + for (final InputMethodInfo imi : imm.getInputMethodList()) { + if (packageName.equals(imi.getPackageName())) { + return imi; + } + } + return null; + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/ViewLayoutUtils.java b/java/src/org/kelar/inputmethod/latin/utils/ViewLayoutUtils.java new file mode 100644 index 000000000..3940375bb --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/ViewLayoutUtils.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; + +public final class ViewLayoutUtils { + private ViewLayoutUtils() { + // This utility class is not publicly instantiable. + } + + public static MarginLayoutParams newLayoutParam(final ViewGroup placer, final int width, + final int height) { + if (placer instanceof FrameLayout) { + return new FrameLayout.LayoutParams(width, height); + } else if (placer instanceof RelativeLayout) { + return new RelativeLayout.LayoutParams(width, height); + } else if (placer == null) { + throw new NullPointerException("placer is null"); + } else { + throw new IllegalArgumentException("placer is neither FrameLayout nor RelativeLayout: " + + placer.getClass().getName()); + } + } + + public static void placeViewAt(final View view, final int x, final int y, final int w, + final int h) { + final ViewGroup.LayoutParams lp = view.getLayoutParams(); + if (lp instanceof MarginLayoutParams) { + final MarginLayoutParams marginLayoutParams = (MarginLayoutParams)lp; + marginLayoutParams.width = w; + marginLayoutParams.height = h; + marginLayoutParams.setMargins(x, y, 0, 0); + } + } + + public static void updateLayoutHeightOf(final Window window, final int layoutHeight) { + final WindowManager.LayoutParams params = window.getAttributes(); + if (params != null && params.height != layoutHeight) { + params.height = layoutHeight; + window.setAttributes(params); + } + } + + public static void updateLayoutHeightOf(final View view, final int layoutHeight) { + final ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params != null && params.height != layoutHeight) { + params.height = layoutHeight; + view.setLayoutParams(params); + } + } + + public static void updateLayoutGravityOf(final View view, final int layoutGravity) { + final ViewGroup.LayoutParams lp = view.getLayoutParams(); + if (lp instanceof LinearLayout.LayoutParams) { + final LinearLayout.LayoutParams params = (LinearLayout.LayoutParams)lp; + if (params.gravity != layoutGravity) { + params.gravity = layoutGravity; + view.setLayoutParams(params); + } + } else if (lp instanceof FrameLayout.LayoutParams) { + final FrameLayout.LayoutParams params = (FrameLayout.LayoutParams)lp; + if (params.gravity != layoutGravity) { + params.gravity = layoutGravity; + view.setLayoutParams(params); + } + } else { + throw new IllegalArgumentException("Layout parameter doesn't have gravity: " + + lp.getClass().getName()); + } + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/WordInputEventForPersonalization.java b/java/src/org/kelar/inputmethod/latin/utils/WordInputEventForPersonalization.java new file mode 100644 index 000000000..6e7f0603b --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/WordInputEventForPersonalization.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2014 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 org.kelar.inputmethod.latin.utils; + +import android.util.Log; + +import org.kelar.inputmethod.annotations.UsedForTesting; +import org.kelar.inputmethod.latin.NgramContext; +import org.kelar.inputmethod.latin.common.StringUtils; +import org.kelar.inputmethod.latin.define.DecoderSpecificConstants; +import org.kelar.inputmethod.latin.settings.SpacingAndPunctuations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +// Note: this class is used as a parameter type of a native method. You should be careful when you +// rename this class or field name. See BinaryDictionary#addMultipleDictionaryEntriesNative(). +public final class WordInputEventForPersonalization { + private static final String TAG = WordInputEventForPersonalization.class.getSimpleName(); + private static final boolean DEBUG_TOKEN = false; + + public final int[] mTargetWord; + public final int mPrevWordsCount; + public final int[][] mPrevWordArray = + new int[DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM][]; + public final boolean[] mIsPrevWordBeginningOfSentenceArray = + new boolean[DecoderSpecificConstants.MAX_PREV_WORD_COUNT_FOR_N_GRAM]; + // Time stamp in seconds. + public final int mTimestamp; + + @UsedForTesting + public WordInputEventForPersonalization(final CharSequence targetWord, + final NgramContext ngramContext, final int timestamp) { + mTargetWord = StringUtils.toCodePointArray(targetWord); + mPrevWordsCount = ngramContext.getPrevWordCount(); + ngramContext.outputToArray(mPrevWordArray, mIsPrevWordBeginningOfSentenceArray); + mTimestamp = timestamp; + } + + // Process a list of words and return a list of {@link WordInputEventForPersonalization} + // objects. + public static ArrayList<WordInputEventForPersonalization> createInputEventFrom( + final List<String> tokens, final int timestamp, + final SpacingAndPunctuations spacingAndPunctuations, final Locale locale) { + final ArrayList<WordInputEventForPersonalization> inputEvents = new ArrayList<>(); + final int N = tokens.size(); + NgramContext ngramContext = NgramContext.EMPTY_PREV_WORDS_INFO; + for (int i = 0; i < N; ++i) { + final String tempWord = tokens.get(i); + if (StringUtils.isEmptyStringOrWhiteSpaces(tempWord)) { + // just skip this token + if (DEBUG_TOKEN) { + Log.d(TAG, "--- isEmptyStringOrWhiteSpaces: \"" + tempWord + "\""); + } + continue; + } + if (!DictionaryInfoUtils.looksValidForDictionaryInsertion( + tempWord, spacingAndPunctuations)) { + if (DEBUG_TOKEN) { + Log.d(TAG, "--- not looksValidForDictionaryInsertion: \"" + + tempWord + "\""); + } + // Sentence terminator found. Split. + // TODO: Detect whether the context is beginning-of-sentence. + ngramContext = NgramContext.EMPTY_PREV_WORDS_INFO; + continue; + } + if (DEBUG_TOKEN) { + Log.d(TAG, "--- word: \"" + tempWord + "\""); + } + final WordInputEventForPersonalization inputEvent = + detectWhetherVaildWordOrNotAndGetInputEvent( + ngramContext, tempWord, timestamp, locale); + if (inputEvent == null) { + continue; + } + inputEvents.add(inputEvent); + ngramContext = ngramContext.getNextNgramContext(new NgramContext.WordInfo(tempWord)); + } + return inputEvents; + } + + private static WordInputEventForPersonalization detectWhetherVaildWordOrNotAndGetInputEvent( + final NgramContext ngramContext, final String targetWord, final int timestamp, + final Locale locale) { + if (locale == null) { + return null; + } + return new WordInputEventForPersonalization(targetWord, ngramContext, timestamp); + } +} diff --git a/java/src/org/kelar/inputmethod/latin/utils/XmlParseUtils.java b/java/src/org/kelar/inputmethod/latin/utils/XmlParseUtils.java new file mode 100644 index 000000000..cbd476413 --- /dev/null +++ b/java/src/org/kelar/inputmethod/latin/utils/XmlParseUtils.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethod.latin.utils; + +import android.content.res.TypedArray; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +public final class XmlParseUtils { + private XmlParseUtils() { + // This utility class is not publicly instantiable. + } + + @SuppressWarnings("serial") + public static class ParseException extends XmlPullParserException { + public ParseException(final String msg, final XmlPullParser parser) { + super(msg + " at " + parser.getPositionDescription()); + } + } + + @SuppressWarnings("serial") + public static final class IllegalStartTag extends ParseException { + public IllegalStartTag(final XmlPullParser parser, final String tag, final String parent) { + super("Illegal start tag " + tag + " in " + parent, parser); + } + } + + @SuppressWarnings("serial") + public static final class IllegalEndTag extends ParseException { + public IllegalEndTag(final XmlPullParser parser, final String tag, final String parent) { + super("Illegal end tag " + tag + " in " + parent, parser); + } + } + + @SuppressWarnings("serial") + public static final class IllegalAttribute extends ParseException { + public IllegalAttribute(final XmlPullParser parser, final String tag, + final String attribute) { + super("Tag " + tag + " has illegal attribute " + attribute, parser); + } + } + + @SuppressWarnings("serial") + public static final class NonEmptyTag extends ParseException{ + public NonEmptyTag(final XmlPullParser parser, final String tag) { + super(tag + " must be empty tag", parser); + } + } + + public static void checkEndTag(final String tag, final XmlPullParser parser) + throws XmlPullParserException, IOException { + if (parser.next() == XmlPullParser.END_TAG && tag.equals(parser.getName())) + return; + throw new NonEmptyTag(parser, tag); + } + + public static void checkAttributeExists(final TypedArray attr, final int attrId, + final String attrName, final String tag, final XmlPullParser parser) + throws XmlPullParserException { + if (attr.hasValue(attrId)) { + return; + } + throw new ParseException( + "No " + attrName + " attribute found in <" + tag + "/>", parser); + } +} diff --git a/java/src/org/kelar/inputmethodcommon/InputMethodSettingsActivity.java b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsActivity.java new file mode 100644 index 000000000..1e304c3c4 --- /dev/null +++ b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsActivity.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethodcommon; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.preference.PreferenceActivity; + +/** + * This is a helper class for an IME's settings preference activity. It's recommended for every + * IME to have its own settings preference activity which inherits this class. + */ +public abstract class InputMethodSettingsActivity extends PreferenceActivity + implements InputMethodSettingsInterface { + private final InputMethodSettingsImpl mSettings = new InputMethodSettingsImpl(); + @SuppressWarnings("deprecation") + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(this)); + mSettings.init(this, getPreferenceScreen()); + } + + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(int resId) { + mSettings.setInputMethodSettingsCategoryTitle(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(CharSequence title) { + mSettings.setInputMethodSettingsCategoryTitle(title); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(int resId) { + mSettings.setSubtypeEnablerTitle(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(CharSequence title) { + mSettings.setSubtypeEnablerTitle(title); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(int resId) { + mSettings.setSubtypeEnablerIcon(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(Drawable drawable) { + mSettings.setSubtypeEnablerIcon(drawable); + } + + /** + * {@inheritDoc} + */ + @Override + public void onResume() { + super.onResume(); + mSettings.updateSubtypeEnabler(); + } +} diff --git a/java/src/org/kelar/inputmethodcommon/InputMethodSettingsFragment.java b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsFragment.java new file mode 100644 index 000000000..4b1c5c7e8 --- /dev/null +++ b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsFragment.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethodcommon; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.preference.PreferenceFragment; + +/** + * This is a helper class for an IME's settings preference fragment. It's recommended for every + * IME to have its own settings preference fragment which inherits this class. + */ +public abstract class InputMethodSettingsFragment extends PreferenceFragment + implements InputMethodSettingsInterface { + private final InputMethodSettingsImpl mSettings = new InputMethodSettingsImpl(); + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Context context = getActivity(); + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(context)); + mSettings.init(context, getPreferenceScreen()); + } + + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(int resId) { + mSettings.setInputMethodSettingsCategoryTitle(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(CharSequence title) { + mSettings.setInputMethodSettingsCategoryTitle(title); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(int resId) { + mSettings.setSubtypeEnablerTitle(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(CharSequence title) { + mSettings.setSubtypeEnablerTitle(title); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(int resId) { + mSettings.setSubtypeEnablerIcon(resId); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(Drawable drawable) { + mSettings.setSubtypeEnablerIcon(drawable); + } + + /** + * {@inheritDoc} + */ + @Override + public void onResume() { + super.onResume(); + mSettings.updateSubtypeEnabler(); + } +} diff --git a/java/src/org/kelar/inputmethodcommon/InputMethodSettingsImpl.java b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsImpl.java new file mode 100644 index 000000000..6f1b9478f --- /dev/null +++ b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsImpl.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethodcommon; + +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.preference.Preference; +import android.preference.PreferenceScreen; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import android.view.inputmethod.InputMethodSubtype; + +import java.util.List; + +/* package private */ class InputMethodSettingsImpl implements InputMethodSettingsInterface { + private Preference mSubtypeEnablerPreference; + private int mInputMethodSettingsCategoryTitleRes; + private CharSequence mInputMethodSettingsCategoryTitle; + private int mSubtypeEnablerTitleRes; + private CharSequence mSubtypeEnablerTitle; + private int mSubtypeEnablerIconRes; + private Drawable mSubtypeEnablerIcon; + private InputMethodManager mImm; + private InputMethodInfo mImi; + + /** + * Initialize internal states of this object. + * @param context the context for this application. + * @param prefScreen a PreferenceScreen of PreferenceActivity or PreferenceFragment. + * @return true if this application is an IME and has two or more subtypes, false otherwise. + */ + public boolean init(final Context context, final PreferenceScreen prefScreen) { + mImm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + mImi = getMyImi(context, mImm); + if (mImi == null || mImi.getSubtypeCount() <= 1) { + return false; + } + final Intent intent = new Intent(Settings.ACTION_INPUT_METHOD_SUBTYPE_SETTINGS); + intent.putExtra(Settings.EXTRA_INPUT_METHOD_ID, mImi.getId()); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + mSubtypeEnablerPreference = new Preference(context); + mSubtypeEnablerPreference.setIntent(intent); + prefScreen.addPreference(mSubtypeEnablerPreference); + updateSubtypeEnabler(); + return true; + } + + private static InputMethodInfo getMyImi(Context context, InputMethodManager imm) { + final List<InputMethodInfo> imis = imm.getInputMethodList(); + for (int i = 0; i < imis.size(); ++i) { + final InputMethodInfo imi = imis.get(i); + if (imis.get(i).getPackageName().equals(context.getPackageName())) { + return imi; + } + } + return null; + } + + private static String getEnabledSubtypesLabel( + Context context, InputMethodManager imm, InputMethodInfo imi) { + if (context == null || imm == null || imi == null) return null; + final List<InputMethodSubtype> subtypes = imm.getEnabledInputMethodSubtypeList(imi, true); + final StringBuilder sb = new StringBuilder(); + final int N = subtypes.size(); + for (int i = 0; i < N; ++i) { + final InputMethodSubtype subtype = subtypes.get(i); + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(subtype.getDisplayName(context, imi.getPackageName(), + imi.getServiceInfo().applicationInfo)); + } + return sb.toString(); + } + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(int resId) { + mInputMethodSettingsCategoryTitleRes = resId; + updateSubtypeEnabler(); + } + + /** + * {@inheritDoc} + */ + @Override + public void setInputMethodSettingsCategoryTitle(CharSequence title) { + mInputMethodSettingsCategoryTitleRes = 0; + mInputMethodSettingsCategoryTitle = title; + updateSubtypeEnabler(); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(int resId) { + mSubtypeEnablerTitleRes = resId; + updateSubtypeEnabler(); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerTitle(CharSequence title) { + mSubtypeEnablerTitleRes = 0; + mSubtypeEnablerTitle = title; + updateSubtypeEnabler(); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(int resId) { + mSubtypeEnablerIconRes = resId; + updateSubtypeEnabler(); + } + + /** + * {@inheritDoc} + */ + @Override + public void setSubtypeEnablerIcon(Drawable drawable) { + mSubtypeEnablerIconRes = 0; + mSubtypeEnablerIcon = drawable; + updateSubtypeEnabler(); + } + + public void updateSubtypeEnabler() { + final Preference pref = mSubtypeEnablerPreference; + if (pref == null) { + return; + } + final Context context = pref.getContext(); + final CharSequence title; + if (mSubtypeEnablerTitleRes != 0) { + title = context.getString(mSubtypeEnablerTitleRes); + } else { + title = mSubtypeEnablerTitle; + } + pref.setTitle(title); + final Intent intent = pref.getIntent(); + if (intent != null) { + intent.putExtra(Intent.EXTRA_TITLE, title); + } + final String summary = getEnabledSubtypesLabel(context, mImm, mImi); + if (!TextUtils.isEmpty(summary)) { + pref.setSummary(summary); + } + if (mSubtypeEnablerIconRes != 0) { + pref.setIcon(mSubtypeEnablerIconRes); + } else { + pref.setIcon(mSubtypeEnablerIcon); + } + } +} diff --git a/java/src/org/kelar/inputmethodcommon/InputMethodSettingsInterface.java b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsInterface.java new file mode 100644 index 000000000..fd7a421c0 --- /dev/null +++ b/java/src/org/kelar/inputmethodcommon/InputMethodSettingsInterface.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2011 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 org.kelar.inputmethodcommon; + +import android.graphics.drawable.Drawable; + +/** + * InputMethodSettingsInterface is the interface for adding IME related preferences to + * PreferenceActivity or PreferenceFragment. + */ +public interface InputMethodSettingsInterface { + /** + * Sets the title for the input method settings category with a resource ID. + * @param resId The resource ID of the title. + */ + public void setInputMethodSettingsCategoryTitle(int resId); + + /** + * Sets the title for the input method settings category with a CharSequence. + * @param title The title for this preference. + */ + public void setInputMethodSettingsCategoryTitle(CharSequence title); + + /** + * Sets the title for the input method enabler preference for launching subtype enabler with a + * resource ID. + * @param resId The resource ID of the title. + */ + public void setSubtypeEnablerTitle(int resId); + + /** + * Sets the title for the input method enabler preference for launching subtype enabler with a + * CharSequence. + * @param title The title for this preference. + */ + public void setSubtypeEnablerTitle(CharSequence title); + + /** + * Sets the icon for the preference for launching subtype enabler with a resource ID. + * @param resId The resource id of an optional icon for the preference. + */ + public void setSubtypeEnablerIcon(int resId); + + /** + * Sets the icon for the Preference for launching subtype enabler with a Drawable. + * @param drawable The drawable of an optional icon for the preference. + */ + public void setSubtypeEnablerIcon(Drawable drawable); +} |