From 55377f28b0207fba8ca11b86eaf5e9461deb3527 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 15 Sep 2026 11:53:43 +0300 Subject: [PATCH] fix: preserve custom keyboard in emoji picker search Keep the remembered custom slot and normal layout options while searching. Return through KeyboardState on explicit search exit without navigating or reclaiming pointer drawing during panel teardown. Add Robolectric coverage for the magnifier and close controls, all five custom slots, options, local query input, RTL, selection, and panel transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/keyboard/KeyboardLayoutSet.kt | 11 + .../keyboard/keyboard/KeyboardSwitcher.kt | 34 +- .../keyboard/emoji/EmojiPalettesView.kt | 33 +- .../keyboard/internal/KeyboardState.kt | 5 +- .../keyboard/emoji/EmojiSearchKeyboardTest.kt | 458 ++++++++++++++++++ 5 files changed, 507 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/helium314/keyboard/keyboard/emoji/EmojiSearchKeyboardTest.kt diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.kt index 81e2c356c..9c8d5da42 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.kt @@ -21,6 +21,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.getOrCreate import helium314.keyboard.latin.RichInputMethodManager import helium314.keyboard.latin.RichInputMethodSubtype import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.settings.SettingsValues import helium314.keyboard.latin.utils.InputTypeUtils import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.ResourceUtils @@ -224,6 +225,16 @@ class KeyboardLayoutSet internal constructor( return this } + fun setKeyboardOptions(settingsValues: SettingsValues): Builder = + setVoiceInputKeyEnabled(settingsValues.mShowsVoiceInputKey) + .setNumberRowEnabled(settingsValues.mShowsNumberRow) + .setNumberRowInSymbolsEnabled(settingsValues.mShowsNumberRowInSymbols) + .setCompactNumberRowInSymbolsEnabled(settingsValues.mCompactNumberRowInSymbols) + .setLanguageSwitchKeyEnabled(settingsValues.isLanguageSwitchKeyEnabled()) + .setEmojiKeyEnabled(settingsValues.mShowsEmojiKey) + .setSplitLayoutEnabled(settingsValues.mIsSplitKeyboardEnabled) + .setOneHandedModeEnabled(settingsValues.mOneHandedModeEnabled) + fun build(): KeyboardLayoutSet { if (!mSubtypeSpecified) { throw RuntimeException("KeyboardLayoutSet subtype is not specified") diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.kt index 1f762b3ca..1cc0f2731 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.kt @@ -158,14 +158,7 @@ class KeyboardSwitcher private constructor() : KeyboardState.SwitchActions { val richImm = mRichImm ?: RichInputMethodManager.getInstance() mKeyboardLayoutSet = builder.setKeyboardGeometry(keyboardWidth, keyboardHeight) .setSubtype(richImm.currentSubtype) - .setVoiceInputKeyEnabled(settingsValues.mShowsVoiceInputKey) - .setNumberRowEnabled(settingsValues.mShowsNumberRow) - .setNumberRowInSymbolsEnabled(settingsValues.mShowsNumberRowInSymbols) - .setCompactNumberRowInSymbolsEnabled(settingsValues.mCompactNumberRowInSymbols) - .setLanguageSwitchKeyEnabled(settingsValues.isLanguageSwitchKeyEnabled()) - .setEmojiKeyEnabled(settingsValues.mShowsEmojiKey) - .setSplitLayoutEnabled(settingsValues.mIsSplitKeyboardEnabled) - .setOneHandedModeEnabled(oneHandedModeEnabled) + .setKeyboardOptions(settingsValues) .setInternalAction(internalAction) .build() val state = mState ?: return @@ -177,14 +170,7 @@ class KeyboardSwitcher private constructor() : KeyboardState.SwitchActions { val defaults = SubtypeUtilsAdditional.createDefaultSubtype(richImm.currentSubtypeLocale) mKeyboardLayoutSet = builder.setKeyboardGeometry(keyboardWidth, keyboardHeight) .setSubtype(RichInputMethodSubtype.get(defaults)) - .setVoiceInputKeyEnabled(settingsValues.mShowsVoiceInputKey) - .setNumberRowEnabled(settingsValues.mShowsNumberRow) - .setNumberRowInSymbolsEnabled(settingsValues.mShowsNumberRowInSymbols) - .setCompactNumberRowInSymbolsEnabled(settingsValues.mCompactNumberRowInSymbols) - .setLanguageSwitchKeyEnabled(settingsValues.isLanguageSwitchKeyEnabled()) - .setEmojiKeyEnabled(settingsValues.mShowsEmojiKey) - .setSplitLayoutEnabled(settingsValues.mIsSplitKeyboardEnabled) - .setOneHandedModeEnabled(oneHandedModeEnabled) + .setKeyboardOptions(settingsValues) .build() state.onLoadKeyboard(currentAutoCapsState, currentRecapitalizeState, oneHandedModeEnabled) showToast("error loading the keyboard, falling back to defaults", false) @@ -247,6 +233,11 @@ class KeyboardSwitcher private constructor() : KeyboardState.SwitchActions { mState?.onResetKeyboardStateToAlphabet(currentAutoCapsState, currentRecapitalizeState) } + // A secondary view may have been shown directly, without changing the keyboard state. + fun returnToAlphabetKeyboard() { + mState?.setAlphabetKeyboard(0, null) + } + fun onPressKey(code: Int, isSinglePointer: Boolean, currentAutoCapsState: Int, currentRecapitalizeState: RecapitalizeMode?) { mState?.onPressKey(code, isSinglePointer, currentAutoCapsState, currentRecapitalizeState) } @@ -296,7 +287,14 @@ class KeyboardSwitcher private constructor() : KeyboardState.SwitchActions { override fun setCustomKeyboard(customIndex: Int) { if (DEBUG_ACTION) Log.d(TAG, "setCustomKeyboard: $customIndex") - val elementId = when (customIndex) { + setKeyboard(customKeyboardElementId(customIndex), KeyboardSwitchState.OTHER) + } + + val activeAlphabetKeyboardId: Int + get() = customKeyboardElementId(mState?.lastCustomIndex ?: 0) + + private fun customKeyboardElementId(customIndex: Int): Int = + when (customIndex) { 1 -> KeyboardId.ELEMENT_CUSTOM1 2 -> KeyboardId.ELEMENT_CUSTOM2 3 -> KeyboardId.ELEMENT_CUSTOM3 @@ -304,8 +302,6 @@ class KeyboardSwitcher private constructor() : KeyboardState.SwitchActions { 5 -> KeyboardId.ELEMENT_CUSTOM5 else -> KeyboardId.ELEMENT_ALPHABET } - setKeyboard(elementId, KeyboardSwitchState.OTHER) - } fun isImeSuppressedByHardwareKeyboard( settingsValues: SettingsValues, diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.kt b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.kt index 2c8df8a2d..93e0412e4 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.kt @@ -195,6 +195,7 @@ class EmojiPalettesView @JvmOverloads constructor( private var mIsDownloadingEmojiDict = false private var mOriginalActionListener: KeyboardActionListener? = null private var mSearchKeyboardLayoutSet: KeyboardLayoutSet? = null + private var mSearchAlphabetKeyboardId = KeyboardId.ELEMENT_ALPHABET private var mEditorInfo: EditorInfo? = null @@ -499,7 +500,7 @@ class EmojiPalettesView @JvmOverloads constructor( val bottomRow = findViewById(R.id.bottom_row_keyboard) val currentElementId = bottomRow.keyboard?.mId?.mElementId ?: KeyboardId.ELEMENT_ALPHABET val isOnSymbols = currentElementId == KeyboardId.ELEMENT_SYMBOLS || currentElementId == KeyboardId.ELEMENT_SYMBOLS_SHIFTED - val targetId = if (isOnSymbols) KeyboardId.ELEMENT_ALPHABET else KeyboardId.ELEMENT_SYMBOLS + val targetId = if (isOnSymbols) mSearchAlphabetKeyboardId else KeyboardId.ELEMENT_SYMBOLS bottomRow.setKeyboard(layoutSet.getKeyboard(targetId)) bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) } @@ -511,7 +512,7 @@ class EmojiPalettesView @JvmOverloads constructor( KeyboardId.ELEMENT_SYMBOLS -> KeyboardId.ELEMENT_SYMBOLS_SHIFTED KeyboardId.ELEMENT_SYMBOLS_SHIFTED -> KeyboardId.ELEMENT_SYMBOLS KeyboardId.ELEMENT_ALPHABET -> KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED - else -> KeyboardId.ELEMENT_ALPHABET + else -> mSearchAlphabetKeyboardId } bottomRow.setKeyboard(layoutSet.getKeyboard(targetId)) bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) @@ -638,7 +639,7 @@ class EmojiPalettesView @JvmOverloads constructor( val builder = KeyboardLayoutSet.Builder(ctx, null) builder.setSubtype(RichInputMethodManager.getInstance().currentSubtype) - builder.setSplitLayoutEnabled(Settings.getValues().mIsSplitKeyboardEnabled) + builder.setKeyboardOptions(Settings.getValues()) builder.setKeyboardGeometry( ResourceUtils.getKeyboardWidth(ctx, Settings.getValues()), ResourceUtils.getSecondaryKeyboardHeight(resources, Settings.getValues()) @@ -646,7 +647,8 @@ class EmojiPalettesView @JvmOverloads constructor( val searchKeyboardLayoutSet = builder.build() mSearchKeyboardLayoutSet = searchKeyboardLayoutSet - bottomRow.setKeyboard(searchKeyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET)) + mSearchAlphabetKeyboardId = KeyboardSwitcher.getInstance().activeAlphabetKeyboardId + bottomRow.setKeyboard(searchKeyboardLayoutSet.getKeyboard(mSearchAlphabetKeyboardId)) bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) mSearchBar?.requestFocus() @@ -657,12 +659,17 @@ class EmojiPalettesView @JvmOverloads constructor( } } - private fun stopSearchMode() { + private fun stopSearchMode(returnToKeyboard: Boolean = true) { Log.d("EmojiSearch", "stopSearchMode") if (!mInSearchMode) return mInSearchMode = false - setupBottomRowKeyboard(null, mOriginalActionListener) + if (returnToKeyboard) { + setupBottomRowKeyboard(null, mOriginalActionListener) + } else { + // Do not take PointerTracker back from the panel that is replacing us. + findViewById(R.id.bottom_row_keyboard)?.setKeyboardActionListener(mOriginalActionListener) + } setupCategoryTabs() mEmojiCategoryPageIndicatorView?.visibility = View.GONE @@ -677,8 +684,8 @@ class EmojiPalettesView @JvmOverloads constructor( PointerTracker.setKeyboardActionListener(it) } - if (isAttachedToWindow) { - KeyboardSwitcher.getInstance().setAlphabetKeyboard() + if (returnToKeyboard && isAttachedToWindow) { + KeyboardSwitcher.getInstance().returnToAlphabetKeyboard() } if (isInLayout) { @@ -724,7 +731,7 @@ class EmojiPalettesView @JvmOverloads constructor( editorInfo: EditorInfo?, keyboardActionListener: KeyboardActionListener? ) { - stopSearchMode() + stopSearchMode(returnToKeyboard = false) mEditorInfo = editorInfo mKeyboardActionListener = keyboardActionListener ?: KeyboardActionListener.EMPTY_LISTENER initialize() @@ -760,11 +767,11 @@ class EmojiPalettesView @JvmOverloads constructor( private fun setupBottomRowKeyboard(editorInfo: EditorInfo?, keyboardActionListener: KeyboardActionListener?) { val keyboardView = findViewById(R.id.bottom_row_keyboard) ?: return + keyboardView.setKeyboardActionListener(keyboardActionListener) if (!isAttachedToWindow) return keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) val ei = editorInfo ?: mEditorInfo - keyboardView.setKeyboardActionListener(keyboardActionListener) try { PointerTracker.switchTo(keyboardView) @@ -856,7 +863,7 @@ class EmojiPalettesView @JvmOverloads constructor( if (!initialized) return if (mInSearchMode) { - stopSearchMode() + stopSearchMode(returnToKeyboard = false) } getRecentsKeyboard().flushPendingRecentKeys() @@ -1023,14 +1030,14 @@ class EmojiPalettesView @JvmOverloads constructor( override fun setVisibility(visibility: Int) { if (visibility != View.VISIBLE && mInSearchMode) { - stopSearchMode() + stopSearchMode(returnToKeyboard = false) } super.setVisibility(visibility) } override fun onDetachedFromWindow() { if (mInSearchMode) { - stopSearchMode() + stopSearchMode(returnToKeyboard = false) } super.onDetachedFromWindow() } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt index 11b8e60bc..564b7ddc3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt @@ -67,7 +67,8 @@ class KeyboardState(private val switchActions: SwitchActions) { private var mode = Mode.ALPHABET private var modeBeforeNumpad = Mode.ALPHABET // ponytail: track active custom layout index, 0 means default - private var lastCustomIndex = 0 + var lastCustomIndex = 0 + private set private var isSymbolShifted = false private var prevMainKeyboardWasShiftLocked = false private var prevSymbolsKeyboardWasShifted = false @@ -249,7 +250,7 @@ class KeyboardState(private val switchActions: SwitchActions) { } } - private fun setAlphabetKeyboard(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?) { + fun setAlphabetKeyboard(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?) { if (DebugFlags.DEBUG_ENABLED) { Log.d(TAG, "setAlphabetKeyboard: ${stateToString(autoCapsFlags, recapitalizeMode)}") } diff --git a/app/src/test/java/helium314/keyboard/keyboard/emoji/EmojiSearchKeyboardTest.kt b/app/src/test/java/helium314/keyboard/keyboard/emoji/EmojiSearchKeyboardTest.kt new file mode 100644 index 000000000..39810e1cc --- /dev/null +++ b/app/src/test/java/helium314/keyboard/keyboard/emoji/EmojiSearchKeyboardTest.kt @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.keyboard.emoji + +import android.app.Activity +import android.content.res.AssetManager +import android.text.InputType +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import helium314.keyboard.ShadowInputMethodManager2 +import helium314.keyboard.ShadowLocaleManagerCompat +import helium314.keyboard.ShadowProximityInfo +import helium314.keyboard.event.Event +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.keyboard.KeyboardActionListener +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.keyboard.KeyboardTheme +import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.LatinIME +import helium314.keyboard.latin.R +import helium314.keyboard.latin.RichInputMethodManager +import helium314.keyboard.latin.RichInputMethodSubtype +import helium314.keyboard.latin.SingleDictionaryFacilitator +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.LayoutType +import helium314.keyboard.latin.utils.LayoutType.Companion.toExtraValue +import helium314.keyboard.latin.utils.LayoutUtilsCustom +import helium314.keyboard.latin.utils.SubtypeUtilsAdditional +import helium314.keyboard.latin.utils.prefs +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], shadows = [ + ShadowInputMethodManager2::class, + ShadowLocaleManagerCompat::class, + ShadowProximityInfo::class, +]) +class EmojiSearchKeyboardTest { + private lateinit var ime: LatinIME + private lateinit var activity: Activity + private lateinit var root: LinearLayout + private lateinit var tabs: LinearLayout + private lateinit var palettes: EmojiPalettesView + private lateinit var main: MainKeyboardView + private lateinit var bottom: MainKeyboardView + private lateinit var subtype: RichInputMethodSubtype + private val switcher get() = KeyboardSwitcher.getInstance() + private val host = Mockito.mock(KeyboardActionListener::class.java) + private val searchListener get() = field(bottom, "mKeyboardActionListener") as KeyboardActionListener + private val searchBar get() = descendants(tabs).filterIsInstance().single() + private val stateMode get() = field(field(switcher, "mState")!!, "mode").toString() + + @Before + fun setUp() { + ime = Robolectric.setupService(LatinIME::class.java) + ime.prefs().edit() + .clear() + .putBoolean(Settings.PREF_SHOW_NUMBER_ROW, true) + .putBoolean(Settings.PREF_SHOW_NUMBER_ROW_IN_SYMBOLS, true) + .putBoolean(Settings.PREF_COMPACT_NUMBER_ROW_IN_SYMBOLS, true) + .putBoolean(Settings.PREF_SPLIT_TOOLBAR, false) + .commit() + val layouts = LayoutType.getLayoutMap(null).apply { put(LayoutType.MAIN, "qwerty") } + for ((index, type) in customTypes.withIndex()) { + val name = LayoutUtilsCustom.getLayoutName("Search slot ${index + 1}", type) + LayoutUtilsCustom.getLayoutFile(name + "txt", type, ime).writeText("z\nx\nv\n\nj\nk\nl\n\n${index + 1}\nb\nn\n") + layouts[type] = name + } + LayoutUtilsCustom.onLayoutFileChanged() + subtype = RichInputMethodSubtype.get(SubtypeUtilsAdditional.createAdditionalSubtype( + Locale.US, "${Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET}=${layouts.toExtraValue()}", true, true + )) + RichInputMethodManager.forceSubtype(subtype.rawSubtype) + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + activity.setTheme(KeyboardTheme.getKeyboardTheme(ime).mStyleId) + // Android asset paths use '/', including when these tests run on Windows. + val assets = Mockito.mock(AssetManager::class.java) + Mockito.`when`(assets.list(Mockito.anyString())).thenAnswer { + ime.assets.list(it.getArgument(0).replace('\\', '/')) + } + Mockito.`when`(assets.open(Mockito.anyString())).thenAnswer { + ime.assets.open(it.getArgument(0).replace('\\', '/')) + } + val context = object : ContextThemeWrapper(ime, KeyboardTheme.getKeyboardTheme(ime).mStyleId) { + override fun getAssets() = assets + } + root = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + tabs = LinearLayout(context) + main = MainKeyboardView(context) + palettes = LayoutInflater.from(context).inflate(R.layout.emoji_palettes_view, root, false) as EmojiPalettesView + bottom = palettes.findViewById(R.id.bottom_row_keyboard) + root.addView(tabs) + root.addView(main) + root.addView(palettes) + activity.setContentView(root) + setField(switcher, "mThemeContext", context) + setField(switcher, "mKeyboardView", main) + setField(switcher, "mEmojiPalettesView", palettes) + setField(switcher, "mEmojiTabStripView", tabs) + main.setKeyboardActionListener(host) + switcher.loadKeyboard(EditorInfo().apply { inputType = InputType.TYPE_CLASS_TEXT }, Settings.getValues(), 0, null, null) + assertTrue(palettes.isAttachedToWindow) + assertEquals(KeyboardId.ELEMENT_ALPHABET, main.keyboard?.mId?.mElementId) + assertEquals(subtype, main.keyboard!!.mId.mSubtype) + customTypes.forEach { type -> + assertTrue(LayoutUtilsCustom.getLayoutFiles(type, context).any { it.name.startsWith(subtype.layouts[type]!!) }) + } + } + + @After + fun tearDown() { + if (::activity.isInitialized) activity.finish() + if (::ime.isInitialized) ime.onDestroy() + RichInputMethodManager::class.java.getDeclaredField("forcedSubtypeForTesting") + .apply { isAccessible = true }.set(null, null) + } + + @Test + fun magnifierKeepsEachCustomSlotAndQueryOutOfHost() { + for (index in customTypes.indices) { + event(customCodes[index]) + val original = main.keyboard!! + assertEquals(customElements[index], original.mId.mElementId) + assertEquals(listOf("z", "x", "v"), keyRows(original).first().mapNotNull { it.second }) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertEquals(customElements[index], bottom.keyboard!!.mId.mElementId) + assertEquals(subtype, bottom.keyboard!!.mId.mSubtype) + assertEquals(subtype, RichInputMethodManager.getInstance().currentSubtype) + assertEquals(keyRows(original), keyRows(bottom.keyboard!!)) + searchListener.onCodeInput('x'.code, 0, 0, false) + searchListener.onTextInput("cat") + assertEquals("xcat", searchBar.text.toString()) + Mockito.verifyNoInteractions(host) + closeSearch() + } + } + + @Test + fun searchKeepsNormalKeyboardOptions() { + val original = main.keyboard!! + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertTrue(original.mId.mNumberRowEnabled) + assertTrue(bottom.keyboard!!.mId.mNumberRowEnabled) + assertEquals(keyRows(original), keyRows(bottom.keyboard!!)) + assertEquals(original.mId.mNumberRowInSymbols, bottom.keyboard!!.mId.mNumberRowInSymbols) + assertEquals(original.mId.mCompactNumberRowInSymbols, bottom.keyboard!!.mId.mCompactNumberRowInSymbols) + assertEquals(original.mId.mEmojiKeyEnabled, bottom.keyboard!!.mId.mEmojiKeyEnabled) + assertEquals(original.mId.mLanguageSwitchKeyEnabled, bottom.keyboard!!.mId.mLanguageSwitchKeyEnabled) + assertEquals(original.mId.mHasShortcutKey, bottom.keyboard!!.mId.mHasShortcutKey) + } + + @Test + fun cancelRestoresCustomLayoutAndState() { + event(KeyCode.CUSTOM3) + val original = main.keyboard!! + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + closeSearch() + assertFalse(palettes.isShown) + assertEquals(View.VISIBLE, main.visibility) + assertEquals(original.mId, main.keyboard!!.mId) + assertEquals("CUSTOM3", stateMode) + assertSame(host, field(bottom, "mKeyboardActionListener")) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + } + + @Test + fun cancelAlsoReturnsWhenEmojiWasShownWithoutAStateEvent() { + // Hardware-keyboard toggling and keyboard reloads can show the palette directly. + for (code in listOf(KeyCode.CUSTOM2, KeyCode.ALPHA)) { + event(code) + val original = main.keyboard!! + val originalMode = stateMode + switcher.setEmojiKeyboard() + palettes.setKeyboardActionListener(host) + openSearch() + closeSearch() + assertEquals(View.GONE, palettes.visibility) + assertEquals(View.VISIBLE, main.visibility) + assertEquals(original.mId, main.keyboard!!.mId) + assertEquals(originalMode, stateMode) + assertSame(main, staticField(PointerTracker::class.java, "sDrawingProxy")) + } + } + + @Test + fun symbolsAndShiftReturnToTheSearchCustomSlot() { + event(KeyCode.CUSTOM5) + val original = main.keyboard!! + event(KeyCode.SYMBOL) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertEquals(keyRows(original), keyRows(bottom.keyboard!!)) + code(KeyCode.SHIFT) + assertEquals(KeyboardId.ELEMENT_CUSTOM5, bottom.keyboard!!.mId.mElementId) + code(KeyCode.SYMBOL_ALPHA) + assertEquals(KeyboardId.ELEMENT_SYMBOLS, bottom.keyboard!!.mId.mElementId) + assertTrue(bottom.keyboard!!.mId.mNumberRowInSymbols) + assertTrue(bottom.keyboard!!.mId.mCompactNumberRowInSymbols) + code(KeyCode.SHIFT) + assertEquals(KeyboardId.ELEMENT_SYMBOLS_SHIFTED, bottom.keyboard!!.mId.mElementId) + code(KeyCode.SHIFT) + assertEquals(KeyboardId.ELEMENT_SYMBOLS, bottom.keyboard!!.mId.mElementId) + code(KeyCode.SYMBOL_ALPHA) + assertEquals(keyRows(original), keyRows(bottom.keyboard!!)) + code(KeyCode.LANGUAGE_SWITCH) + customCodes.forEach { code(it) } + assertEquals(KeyboardId.ELEMENT_CUSTOM5, bottom.keyboard!!.mId.mElementId) + assertEquals("EMOJI", stateMode) + Mockito.verifyNoInteractions(host) + closeSearch() + assertEquals("CUSTOM5", stateMode) + assertEquals(original.mId, main.keyboard!!.mId) + } + + @Test + fun mainSearchStillSupportsShiftAndSymbols() { + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + code(KeyCode.SHIFT) + assertEquals(KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED, bottom.keyboard!!.mId.mElementId) + code('A'.code) + code(KeyCode.SHIFT) + assertEquals(KeyboardId.ELEMENT_ALPHABET, bottom.keyboard!!.mId.mElementId) + code(KeyCode.SYMBOL) + code('1'.code) + code(KeyCode.SYMBOL_ALPHA) + assertEquals(KeyboardId.ELEMENT_ALPHABET, bottom.keyboard!!.mId.mElementId) + assertEquals("A1", searchBar.text.toString()) + Mockito.verifyNoInteractions(host) + closeSearch() + assertEquals("ALPHABET", stateMode) + } + + @Test + fun hideCleansUpWithoutChangingPanelsOrState() { + event(KeyCode.CUSTOM2) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + val query = searchBar + code('x'.code) + palettes.visibility = View.GONE + assertEquals("", query.text.toString()) + assertFalse(field(palettes, "mInSearchMode") as Boolean) + assertEquals("EMOJI", stateMode) + assertEquals(View.GONE, main.visibility) + assertSame(host, field(bottom, "mKeyboardActionListener")) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertEquals(KeyboardId.ELEMENT_CUSTOM2, bottom.keyboard!!.mId.mElementId) + } + + @Test + fun detachCleansUpWithoutShowingMainKeyboard() { + event(KeyCode.CUSTOM4) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + root.removeView(palettes) + assertFalse(palettes.isAttachedToWindow) + assertFalse(field(palettes, "mInSearchMode") as Boolean) + assertEquals("EMOJI", stateMode) + assertEquals(View.GONE, main.visibility) + assertSame(host, field(bottom, "mKeyboardActionListener")) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + } + + @Test + fun switchingToClipboardDoesNotRestoreMainDuringCleanup() { + event(KeyCode.CUSTOM2) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertTrue(Settings.getValues().mClipboardHistoryEnabled) + event(KeyCode.CLIPBOARD) + assertEquals("CLIPBOARD", stateMode) + assertEquals(View.GONE, main.visibility) + assertEquals(View.GONE, palettes.visibility) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + } + + @Test + fun switchingToNumpadKeepsMainPointerTarget() { + event(KeyCode.CUSTOM2) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + code(KeyCode.SYMBOL) + event(KeyCode.NUMPAD) + assertEquals("NUMPAD", stateMode) + assertEquals(View.GONE, palettes.visibility) + assertEquals(View.VISIBLE, main.visibility) + assertSame(main, staticField(PointerTracker::class.java, "sDrawingProxy")) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + val key = main.keyboard!!.sortedKeys.first { it.code == '1'.code } + val x = key.x + key.width / 2f + main.paddingLeft + val y = key.y + key.height / 2f + main.paddingTop + val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x, y, 0) + val up = MotionEvent.obtain(0, 10, MotionEvent.ACTION_UP, x, y, 0) + try { + main.onTouchEvent(down) + main.onTouchEvent(up) + } finally { + down.recycle() + up.recycle() + } + Mockito.verify(host).onCodeInput(Mockito.eq('1'.code), Mockito.anyInt(), Mockito.anyInt(), Mockito.eq(false)) + } + + @Test + fun searchResultCommitsOnlyEmojiAndKeepsSearchOpen() { + event(KeyCode.CUSTOM1) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + EmojiPalettesView::class.java.getDeclaredField("sDictionaryFacilitator") + .apply { isAccessible = true }.set(null, Mockito.mock(SingleDictionaryFacilitator::class.java)) + openSearch() + val adapter = field(palettes, "mSearchAdapter") as EmojiSearchAdapter + val emoji = String(Character.toChars(0x1F600)) + adapter.submitList(listOf(emoji)) + val holder = adapter.onCreateViewHolder(RecyclerView(activity), 0) + adapter.onBindViewHolder(holder, 0) + assertTrue(holder.itemView.performClick()) + Mockito.verify(host).onTextInput(emoji) + Mockito.verifyNoMoreInteractions(host) + assertTrue(field(palettes, "mInSearchMode") as Boolean) + assertEquals("EMOJI", stateMode) + assertEquals(KeyboardId.ELEMENT_CUSTOM1, bottom.keyboard!!.mId.mElementId) + closeSearch() + assertEquals("CUSTOM1", stateMode) + assertSame(host, staticField(PointerTracker::class.java, "sListener")) + } + + @Test + fun searchKeepsSplitAndOneHandedOptionsAndGeometry() { + Settings.getInstance().writeSplitKeyboardEnabled(true, false) + Settings.getInstance().writeOneHandedModeEnabled(true) + ime.prefs().edit().putBoolean(Settings.PREF_SHOW_EMOJI_KEY, true).commit() + KeyboardLayoutSet.onKeyboardThemeChanged() + switcher.loadKeyboard(EditorInfo(), Settings.getValues(), 0, null, null) + val original = main.keyboard!! + assertTrue(original.mId.mIsSplitLayout) + assertTrue(original.mId.mOneHandedModeEnabled) + assertTrue(original.mId.mEmojiKeyEnabled) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + val search = bottom.keyboard!! + assertEquals(original.mId.mWidth, search.mId.mWidth) + assertEquals(original.mId.mIsSplitLayout, search.mId.mIsSplitLayout) + assertEquals(original.mId.mOneHandedModeEnabled, search.mId.mOneHandedModeEnabled) + assertEquals(original.mId.mEmojiKeyEnabled, search.mId.mEmojiKeyEnabled) + assertEquals(keyRows(original), keyRows(search)) + closeSearch() + assertEquals(original.mId, main.keyboard!!.mId) + } + + @Test + fun rtlSubtypeAndSearchCursorStayLocal() { + val layouts = subtype.layouts.apply { put(LayoutType.MAIN, "arabic") } + subtype = RichInputMethodSubtype.get(SubtypeUtilsAdditional.createAdditionalSubtype( + Locale.forLanguageTag("ar"), "${Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET}=${layouts.toExtraValue()}", false, true + )) + RichInputMethodManager.forceSubtype(subtype.rawSubtype) + switcher.loadKeyboard(EditorInfo(), Settings.getValues(), 0, null, null) + event(KeyCode.CUSTOM4) + val original = main.keyboard!! + assertTrue(original.mId.mSubtype.isRtlSubtype) + event(KeyCode.EMOJI) + palettes.setKeyboardActionListener(host) + openSearch() + assertEquals(subtype, bottom.keyboard!!.mId.mSubtype) + assertEquals(keyRows(original), keyRows(bottom.keyboard!!)) + searchListener.onTextInput("\u0642\u0637") + searchBar.setSelection(2) + assertTrue(searchListener.onHorizontalSpaceSwipe(1)) + assertEquals(1, searchBar.selectionStart) + code(KeyCode.DELETE) + assertEquals("\u0637", searchBar.text.toString()) + Mockito.verifyNoInteractions(host) + closeSearch() + assertEquals(original.mId, main.keyboard!!.mId) + assertEquals("CUSTOM4", stateMode) + } + + private fun code(code: Int) = searchListener.onCodeInput(code, 0, 0, false) + + private fun openSearch() { + val magnifier = descendants(tabs).single { it.contentDescription == "Search Emojis" } + assertTrue(magnifier.performClick()) + assertNotEquals(KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW, bottom.keyboard!!.mId.mElementId) + } + + private fun closeSearch() { + assertTrue(descendants(tabs).filterIsInstance().single().performClick()) + } + + private fun event(code: Int) { + switcher.onEvent(Event.createSoftwareKeypressEvent(code, 0, 0, 0, false), 0, null) + } + + private fun keyRows(keyboard: Keyboard) = keyboard.sortedKeys + .groupBy { it.y }.toSortedMap().values.map { row -> row.sortedBy { it.x }.map { it.code to it.label } } + + private fun descendants(view: View): List = listOf(view) + + if (view is ViewGroup) (0 until view.childCount).flatMap { descendants(view.getChildAt(it)) } else emptyList() + + private fun field(target: Any, name: String): Any? = + target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target) + + private fun setField(target: Any, name: String, value: Any?) { + target.javaClass.getDeclaredField(name).apply { isAccessible = true }.set(target, value) + } + + private fun staticField(type: Class<*>, name: String): Any? = + type.getDeclaredField(name).apply { isAccessible = true }.get(null) + + companion object { + private val customTypes = listOf(LayoutType.CUSTOM1, LayoutType.CUSTOM2, LayoutType.CUSTOM3, LayoutType.CUSTOM4, LayoutType.CUSTOM5) + private val customCodes = listOf(KeyCode.CUSTOM1, KeyCode.CUSTOM2, KeyCode.CUSTOM3, KeyCode.CUSTOM4, KeyCode.CUSTOM5) + private val customElements = listOf(KeyboardId.ELEMENT_CUSTOM1, KeyboardId.ELEMENT_CUSTOM2, KeyboardId.ELEMENT_CUSTOM3, KeyboardId.ELEMENT_CUSTOM4, KeyboardId.ELEMENT_CUSTOM5) + } +}