aboutsummaryrefslogtreecommitdiffstats
path: root/java/src/org
diff options
context:
space:
mode:
authorAmin Bandali <bandali@kelar.org>2024-12-16 21:45:41 -0500
committerAmin Bandali <bandali@kelar.org>2025-01-11 14:17:35 -0500
commite9a0e66716dab4dd3184d009d8920de1961efdfa (patch)
tree02dcc096643d74645bf28459c2834c3d4a2ad7f2 /java/src/org
parentfb3b9360d70596d7e921de8bf7d3ca99564a077e (diff)
downloadlatinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.tar.gz
latinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.tar.xz
latinime-e9a0e66716dab4dd3184d009d8920de1961efdfa.zip
Rename to Kelar Keyboard (org.kelar.inputmethod.latin)
Diffstat (limited to 'java/src/org')
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/AccessibilityLongPressTimer.java67
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/AccessibilityUtils.java266
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/KeyCodeDescriptionMapper.java365
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityDelegate.java326
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/KeyboardAccessibilityNodeProvider.java339
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/MainKeyboardAccessibilityDelegate.java293
-rw-r--r--java/src/org/kelar/inputmethod/accessibility/MoreKeysKeyboardAccessibilityDelegate.java120
-rw-r--r--java/src/org/kelar/inputmethod/compat/ActivityManagerCompatUtils.java46
-rw-r--r--java/src/org/kelar/inputmethod/compat/AppWorkaroundsHelper.java30
-rw-r--r--java/src/org/kelar/inputmethod/compat/AppWorkaroundsUtils.java60
-rw-r--r--java/src/org/kelar/inputmethod/compat/BuildCompatUtils.java36
-rw-r--r--java/src/org/kelar/inputmethod/compat/CharacterCompat.java47
-rw-r--r--java/src/org/kelar/inputmethod/compat/CompatUtils.java218
-rw-r--r--java/src/org/kelar/inputmethod/compat/ConnectivityManagerCompatUtils.java36
-rw-r--r--java/src/org/kelar/inputmethod/compat/CursorAnchorInfoCompatWrapper.java185
-rw-r--r--java/src/org/kelar/inputmethod/compat/EditorInfoCompatUtils.java98
-rw-r--r--java/src/org/kelar/inputmethod/compat/InputConnectionCompatUtils.java64
-rw-r--r--java/src/org/kelar/inputmethod/compat/InputMethodManagerCompatWrapper.java52
-rw-r--r--java/src/org/kelar/inputmethod/compat/InputMethodServiceCompatUtils.java37
-rw-r--r--java/src/org/kelar/inputmethod/compat/InputMethodSubtypeCompatUtils.java103
-rw-r--r--java/src/org/kelar/inputmethod/compat/IntentCompatUtils.java35
-rw-r--r--java/src/org/kelar/inputmethod/compat/LocaleListCompatUtils.java40
-rw-r--r--java/src/org/kelar/inputmethod/compat/LocaleSpanCompatUtils.java218
-rw-r--r--java/src/org/kelar/inputmethod/compat/LooperCompatUtils.java42
-rw-r--r--java/src/org/kelar/inputmethod/compat/NotificationCompatUtils.java83
-rw-r--r--java/src/org/kelar/inputmethod/compat/SettingsSecureCompatUtils.java36
-rw-r--r--java/src/org/kelar/inputmethod/compat/SuggestionSpanUtils.java121
-rw-r--r--java/src/org/kelar/inputmethod/compat/SuggestionsInfoCompatUtils.java47
-rw-r--r--java/src/org/kelar/inputmethod/compat/TextInfoCompatUtils.java67
-rw-r--r--java/src/org/kelar/inputmethod/compat/TextViewCompatUtils.java44
-rw-r--r--java/src/org/kelar/inputmethod/compat/UserDictionaryCompatUtils.java49
-rw-r--r--java/src/org/kelar/inputmethod/compat/UserManagerCompatUtils.java80
-rw-r--r--java/src/org/kelar/inputmethod/compat/ViewCompatUtils.java70
-rw-r--r--java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtils.java43
-rw-r--r--java/src/org/kelar/inputmethod/compat/ViewOutlineProviderCompatUtilsLXX.java72
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ActionBatch.java625
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/AssetFileAddress.java66
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/BadFormatException.java30
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ButtonSwitcher.java170
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/CommonPreferences.java40
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/CompletedDownloadInfo.java36
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryDownloadProgressBar.java173
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryListInterfaceState.java85
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryPackConstants.java72
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryProvider.java541
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionaryService.java280
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsActivity.java54
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DictionarySettingsFragment.java438
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadIdAndStartDate.java29
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadManagerWrapper.java112
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadOverMeteredDialog.java86
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/DownloadRecord.java37
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/EventHandler.java46
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/LogProblemReporter.java35
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MD5Calculator.java46
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataDbHelper.java1155
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataHandler.java173
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataParser.java114
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/MetadataUriGetter.java29
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/PrivateLog.java102
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/ProblemReporter.java24
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/UpdateHandler.java1082
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/WordListMetadata.java135
-rw-r--r--java/src/org/kelar/inputmethod/dictionarypack/WordListPreference.java310
-rw-r--r--java/src/org/kelar/inputmethod/event/Combiner.java51
-rw-r--r--java/src/org/kelar/inputmethod/event/CombinerChain.java137
-rw-r--r--java/src/org/kelar/inputmethod/event/DeadKeyCombiner.java303
-rw-r--r--java/src/org/kelar/inputmethod/event/Event.java319
-rw-r--r--java/src/org/kelar/inputmethod/event/EventDecoder.java24
-rw-r--r--java/src/org/kelar/inputmethod/event/HardwareEventDecoder.java26
-rw-r--r--java/src/org/kelar/inputmethod/event/HardwareKeyboardEventDecoder.java81
-rw-r--r--java/src/org/kelar/inputmethod/event/InputTransaction.java116
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/Key.java1022
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyDetector.java116
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/Keyboard.java261
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardActionListener.java132
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardId.java271
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardLayout.java124
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardLayoutSet.java507
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardSwitcher.java508
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardTheme.java215
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/KeyboardView.java590
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/MainKeyboardView.java893
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/MoreKeysDetector.java55
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboard.java369
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/MoreKeysKeyboardView.java320
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/MoreKeysPanel.java136
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/PointerTracker.java1198
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/ProximityInfo.java405
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/DynamicGridKeyboard.java264
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategory.java470
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiCategoryPageIndicatorView.java70
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiLayoutParams.java94
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPageKeyboardView.java233
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesAdapter.java149
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/emoji/EmojiPalettesView.java486
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/AbstractDrawingPreview.java84
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/AlphabetShiftState.java131
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/BatchInputArbiter.java181
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/BogusMoveEventDetector.java115
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/CodesArrayParser.java107
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/DrawingPreviewPlacerView.java88
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/DrawingProxy.java79
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureEnabler.java54
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureFloatingTextDrawingPreview.java184
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingParams.java58
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeDrawingPoints.java197
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionParams.java109
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureStrokeRecognitionPoints.java334
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingParams.java79
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailDrawingPoints.java276
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/GestureTrailsDrawingPreview.java174
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/HermiteInterpolator.java161
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyDrawParams.java167
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewChoreographer.java209
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewDrawParams.java188
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyPreviewView.java139
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeySpecParser.java258
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyStyle.java52
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyStylesSet.java230
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyVisualAttributes.java148
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardBuilder.java889
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardCodesSet.java83
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardIconsSet.java167
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardParams.java193
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardRow.java187
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardState.java711
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsSet.java151
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/KeyboardTextsTable.java4198
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/MatrixUtils.java166
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/ModifierKeyState.java83
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/MoreKeySpec.java355
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/NonDistinctMultitouchHelper.java115
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/PointerTrackerQueue.java238
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/RoundedLine.java113
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/ShiftKeyState.java69
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/SlidingKeyInputDrawingPreview.java106
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/SmoothingUtils.java102
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/TimerHandler.java234
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/TimerProxy.java133
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/TouchPositionCorrection.java97
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/TypingTimeRecorder.java72
-rw-r--r--java/src/org/kelar/inputmethod/keyboard/internal/UniqueKeysCache.java81
-rw-r--r--java/src/org/kelar/inputmethod/latin/AssetFileAddress.java70
-rw-r--r--java/src/org/kelar/inputmethod/latin/AudioAndHapticFeedbackManager.java134
-rw-r--r--java/src/org/kelar/inputmethod/latin/BackupAgent.java57
-rw-r--r--java/src/org/kelar/inputmethod/latin/BinaryDictionary.java669
-rw-r--r--java/src/org/kelar/inputmethod/latin/BinaryDictionaryFileDumper.java569
-rw-r--r--java/src/org/kelar/inputmethod/latin/BinaryDictionaryGetter.java291
-rw-r--r--java/src/org/kelar/inputmethod/latin/ContactsBinaryDictionary.java176
-rw-r--r--java/src/org/kelar/inputmethod/latin/ContactsContentObserver.java136
-rw-r--r--java/src/org/kelar/inputmethod/latin/ContactsDictionaryConstants.java52
-rw-r--r--java/src/org/kelar/inputmethod/latin/ContactsDictionaryUtils.java55
-rw-r--r--java/src/org/kelar/inputmethod/latin/ContactsManager.java244
-rw-r--r--java/src/org/kelar/inputmethod/latin/DicTraverseSession.java98
-rw-r--r--java/src/org/kelar/inputmethod/latin/Dictionary.java216
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryCollection.java140
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryDumpBroadcastReceiver.java50
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryFacilitator.java176
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorImpl.java736
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorLruCache.java106
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryFacilitatorProvider.java26
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryFactory.java161
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryPackInstallBroadcastReceiver.java141
-rw-r--r--java/src/org/kelar/inputmethod/latin/DictionaryStats.java103
-rw-r--r--java/src/org/kelar/inputmethod/latin/EmojiAltPhysicalKeyDetector.java206
-rw-r--r--java/src/org/kelar/inputmethod/latin/ExpandableBinaryDictionary.java757
-rw-r--r--java/src/org/kelar/inputmethod/latin/InputAttributes.java304
-rw-r--r--java/src/org/kelar/inputmethod/latin/InputView.java252
-rw-r--r--java/src/org/kelar/inputmethod/latin/LastComposedWord.java93
-rw-r--r--java/src/org/kelar/inputmethod/latin/LatinIME.java2033
-rw-r--r--java/src/org/kelar/inputmethod/latin/NgramContext.java291
-rw-r--r--java/src/org/kelar/inputmethod/latin/PunctuationSuggestions.java124
-rw-r--r--java/src/org/kelar/inputmethod/latin/ReadOnlyBinaryDictionary.java127
-rw-r--r--java/src/org/kelar/inputmethod/latin/RichInputConnection.java1033
-rw-r--r--java/src/org/kelar/inputmethod/latin/RichInputMethodManager.java612
-rw-r--r--java/src/org/kelar/inputmethod/latin/RichInputMethodSubtype.java250
-rw-r--r--java/src/org/kelar/inputmethod/latin/Suggest.java434
-rw-r--r--java/src/org/kelar/inputmethod/latin/SuggestedWords.java448
-rw-r--r--java/src/org/kelar/inputmethod/latin/SystemBroadcastReceiver.java159
-rw-r--r--java/src/org/kelar/inputmethod/latin/UserBinaryDictionary.java216
-rw-r--r--java/src/org/kelar/inputmethod/latin/WordComposer.java481
-rw-r--r--java/src/org/kelar/inputmethod/latin/WordListInfo.java31
-rw-r--r--java/src/org/kelar/inputmethod/latin/about/AboutPreferences.java28
-rw-r--r--java/src/org/kelar/inputmethod/latin/accounts/AccountStateChangedListener.java75
-rw-r--r--java/src/org/kelar/inputmethod/latin/accounts/AccountsChangedReceiver.java81
-rw-r--r--java/src/org/kelar/inputmethod/latin/accounts/AuthUtils.java67
-rw-r--r--java/src/org/kelar/inputmethod/latin/accounts/LoginAccountUtils.java47
-rw-r--r--java/src/org/kelar/inputmethod/latin/define/DebugFlags.java31
-rw-r--r--java/src/org/kelar/inputmethod/latin/define/DecoderSpecificConstants.java38
-rw-r--r--java/src/org/kelar/inputmethod/latin/define/JniLibName.java25
-rw-r--r--java/src/org/kelar/inputmethod/latin/define/ProductionFlags.java60
-rw-r--r--java/src/org/kelar/inputmethod/latin/inputlogic/InputLogic.java2353
-rw-r--r--java/src/org/kelar/inputmethod/latin/inputlogic/InputLogicHandler.java221
-rw-r--r--java/src/org/kelar/inputmethod/latin/inputlogic/PrivateCommandPerformer.java40
-rw-r--r--java/src/org/kelar/inputmethod/latin/inputlogic/SpaceState.java54
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/DictionaryHeader.java91
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/FormatSpec.java310
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/NgramProperty.java42
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/ProbabilityInfo.java87
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/UnsupportedFormatException.java26
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/WeightedString.java62
-rw-r--r--java/src/org/kelar/inputmethod/latin/makedict/WordProperty.java201
-rw-r--r--java/src/org/kelar/inputmethod/latin/network/AuthException.java35
-rw-r--r--java/src/org/kelar/inputmethod/latin/network/BlockingHttpClient.java97
-rw-r--r--java/src/org/kelar/inputmethod/latin/network/HttpException.java46
-rw-r--r--java/src/org/kelar/inputmethod/latin/network/HttpUrlConnectionBuilder.java229
-rw-r--r--java/src/org/kelar/inputmethod/latin/permissions/PermissionsActivity.java97
-rw-r--r--java/src/org/kelar/inputmethod/latin/permissions/PermissionsManager.java91
-rw-r--r--java/src/org/kelar/inputmethod/latin/permissions/PermissionsUtil.java93
-rw-r--r--java/src/org/kelar/inputmethod/latin/personalization/AccountUtils.java66
-rw-r--r--java/src/org/kelar/inputmethod/latin/personalization/PersonalizationHelper.java108
-rw-r--r--java/src/org/kelar/inputmethod/latin/personalization/UserHistoryDictionary.java135
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/AccountsSettingsFragment.java508
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/AdditionalFeaturesSettingUtils.java57
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/AdvancedSettingsFragment.java262
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/AppearanceSettingsFragment.java46
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/CorrectionSettingsFragment.java152
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/CustomInputStylePreference.java341
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/CustomInputStyleSettingsFragment.java318
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/DebugSettings.java53
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/DebugSettingsFragment.java288
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/GestureSettingsFragment.java38
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/LocalSettingsConstants.java61
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/PreferencesSettingsFragment.java104
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/RadioButtonPreference.java97
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SeekBarDialogPreference.java147
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/Settings.java458
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SettingsActivity.java87
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SettingsFragment.java101
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SettingsValues.java453
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SettingsValuesForSuggestion.java25
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SpacingAndPunctuations.java155
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/SubScreenFragment.java134
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/TestFragmentActivity.java55
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/ThemeSettingsFragment.java112
-rw-r--r--java/src/org/kelar/inputmethod/latin/settings/TwoStatePreferenceHelper.java82
-rw-r--r--java/src/org/kelar/inputmethod/latin/setup/SetupActivity.java36
-rw-r--r--java/src/org/kelar/inputmethod/latin/setup/SetupStartIndicatorView.java123
-rw-r--r--java/src/org/kelar/inputmethod/latin/setup/SetupStepIndicatorView.java62
-rw-r--r--java/src/org/kelar/inputmethod/latin/setup/SetupWizardActivity.java513
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerService.java244
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSession.java225
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidSpellCheckerSessionFactory.java25
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java390
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SentenceLevelAdapter.java197
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsActivity.java61
-rw-r--r--java/src/org/kelar/inputmethod/latin/spellcheck/SpellCheckerSettingsFragment.java90
-rw-r--r--java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestions.java268
-rw-r--r--java/src/org/kelar/inputmethod/latin/suggestions/MoreSuggestionsView.java117
-rw-r--r--java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripLayoutHelper.java650
-rw-r--r--java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripView.java491
-rw-r--r--java/src/org/kelar/inputmethod/latin/suggestions/SuggestionStripViewAccessor.java27
-rw-r--r--java/src/org/kelar/inputmethod/latin/touchinputconsumer/GestureConsumer.java69
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordContents.java286
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryAddWordFragment.java179
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryList.java165
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionaryLocalePicker.java36
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettings.java352
-rw-r--r--java/src/org/kelar/inputmethod/latin/userdictionary/UserDictionarySettingsUtils.java42
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/AdditionalSubtypeUtils.java238
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ApplicationUtils.java83
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/AsyncResultHolder.java72
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/AutoCorrectionUtils.java62
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/BinaryDictionaryUtils.java128
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/CapsModeUtils.java357
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/CombinedFormatUtils.java109
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/CompletionInfoUtils.java43
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/CursorAnchorInfoUtils.java264
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/DebugLogUtils.java115
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/DialogUtils.java34
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/DictionaryHeaderUtils.java31
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/DictionaryInfoUtils.java613
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ExecutorUtils.java152
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/FeedbackUtils.java38
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/FileTransforms.java38
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/FragmentUtils.java64
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ImportantNoticeUtils.java140
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/InputTypeUtils.java117
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/IntentUtils.java45
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/JniUtils.java41
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/JsonUtils.java103
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/LanguageOnSpacebarUtils.java92
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/LeakGuardHandlerWrapper.java43
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ManagedProfileUtils.java43
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/MetadataFileUriGetter.java39
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/NgramContextUtils.java113
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/RecapitalizeStatus.java221
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ResourceUtils.java319
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/RunInLocale.java53
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ScriptUtils.java195
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/SpannableStringUtils.java183
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/StatsUtils.java108
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/StatsUtilsManager.java56
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/SubtypeLocaleUtils.java351
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/SuggestionResults.java89
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/TargetPackageInfoGetterTask.java67
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/TextRange.java122
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/TypefaceUtils.java108
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/UncachedInputMethodManagerUtils.java84
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/ViewLayoutUtils.java93
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/WordInputEventForPersonalization.java106
-rw-r--r--java/src/org/kelar/inputmethod/latin/utils/XmlParseUtils.java83
-rw-r--r--java/src/org/kelar/inputmethodcommon/InputMethodSettingsActivity.java94
-rw-r--r--java/src/org/kelar/inputmethodcommon/InputMethodSettingsFragment.java95
-rw-r--r--java/src/org/kelar/inputmethodcommon/InputMethodSettingsImpl.java178
-rw-r--r--java/src/org/kelar/inputmethodcommon/InputMethodSettingsInterface.java63
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>
+ * &lt;Keyboard
+ * latin:keyWidth="10%p"
+ * latin:rowHeight="50px"
+ * latin:horizontalGap="2%p"
+ * latin:verticalGap="2%p" &gt;
+ * &lt;Row latin:keyWidth="10%p" &gt;
+ * &lt;Key latin:keyLabel="A" /&gt;
+ * ...
+ * &lt;/Row&gt;
+ * ...
+ * &lt;/Keyboard&gt;
+ * </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>
+ * &lt;!-- xml/keyboard.xml --&gt;
+ * &lt;Keyboard keyboard_attributes*&gt;
+ * &lt;!-- Keyboard Content --&gt;
+ * &lt;Row row_attributes*&gt;
+ * &lt;!-- Row Content --&gt;
+ * &lt;Key key_attributes* /&gt;
+ * &lt;Spacer horizontalGap="32.0dp" /&gt;
+ * &lt;include keyboardLayout="@xml/other_keys"&gt;
+ * ...
+ * &lt;/Row&gt;
+ * &lt;include keyboardLayout="@xml/other_rows"&gt;
+ * ...
+ * &lt;/Keyboard&gt;
+ * </pre>
+ * The XML file which is included in other file must have &lt;merge&gt; as root element,
+ * such as:
+ * <pre>
+ * &lt;!-- xml/other_keys.xml --&gt;
+ * &lt;merge&gt;
+ * &lt;Key key_attributes* /&gt;
+ * ...
+ * &lt;/merge&gt;
+ * </pre>
+ * and
+ * <pre>
+ * &lt;!-- xml/other_rows.xml --&gt;
+ * &lt;merge&gt;
+ * &lt;Row row_attributes*&gt;
+ * &lt;Key key_attributes* /&gt;
+ * &lt;/Row&gt;
+ * ...
+ * &lt;/merge&gt;
+ * </pre>
+ * You can also use switch-case-default tags to select Rows and Keys.
+ * <pre>
+ * &lt;switch&gt;
+ * &lt;case case_attribute*&gt;
+ * &lt;!-- Any valid tags at switch position --&gt;
+ * &lt;/case&gt;
+ * ...
+ * &lt;default&gt;
+ * &lt;!-- Any valid tags at switch position --&gt;
+ * &lt;/default&gt;
+ * &lt;/switch&gt;
+ * </pre>
+ * You can declare Key style and specify styles within Key tags.
+ * <pre>
+ * &lt;switch&gt;
+ * &lt;case mode="email"&gt;
+ * &lt;key-style styleName="f1-key" parentStyle="modifier-key"
+ * keyLabel=".com"
+ * /&gt;
+ * &lt;/case&gt;
+ * &lt;case mode="url"&gt;
+ * &lt;key-style styleName="f1-key" parentStyle="modifier-key"
+ * keyLabel="http://"
+ * /&gt;
+ * &lt;/case&gt;
+ * &lt;/switch&gt;
+ * ...
+ * &lt;Key keyStyle="shift-key" ... /&gt;
+ * </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">&#x058F;</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">&#x0161;,&#x00DF;,&#x015B;</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">&#x010D;,&#x00E7;,&#x0107;</string>
+ // U+010F: "ď" LATIN SMALL LETTER D WITH CARON
+ // <string name="morekeys_d">&#x010F;</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">&#x017E;,&#x017A;,&#x017C;</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);
+}