From 850a27c10235a3e18c7b110bbc65debf67c17d22 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Wed, 30 Sep 2026 09:41:11 +0300 Subject: [PATCH] feat(toolbar): add auto-capitalization controls and JSON actions Toggle the existing saved capitalization preferences from optional toolbar, pinned and custom layout controls. Refresh effective state immediately while preserving manual Shift, Caps Lock, composition and selection. Add accessible saved-but-inactive states and regression coverage. Adapted from 0e8232e33a6e3043839fa0c1c2cfddfda02926f0 onto current upstream without fork-only feature dependencies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../keyboard/KeyboardActionListenerImpl.kt | 10 + .../keyboard/internal/KeyboardIconsSet.kt | 6 + .../keyboard_parser/floris/KeyCode.kt | 4 +- .../keyboard/latin/settings/Settings.kt | 12 + .../keyboard/latin/utils/ToolbarUtils.kt | 23 +- app/src/main/res/drawable/ic_auto_cap.xml | 10 + .../main/res/drawable/ic_force_auto_caps.xml | 10 + app/src/main/res/values/strings.xml | 5 + .../helium314/keyboard/KeyboardParserTest.kt | 54 ++++ .../keyboard/latin/InputLogicTest.kt | 274 +++++++++++++++++- docs/FEATURES.md | 22 +- 11 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/ic_auto_cap.xml create mode 100644 app/src/main/res/drawable/ic_force_auto_caps.xml diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 7d53c6707..1edd12c45 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -237,6 +237,16 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp return } KeyCode.TOGGLE_AUTOCORRECT -> return settings.toggleAutoCorrect() + KeyCode.TOGGLE_AUTO_CAP, KeyCode.TOGGLE_FORCE_AUTO_CAPS -> { + if (primaryCode == KeyCode.TOGGLE_AUTO_CAP) settings.toggleAutoCapitalization() + else settings.toggleForceAutoCapitalization() + // An automatic shift refresh must not consume an explicit one-shot Shift. + if (keyboardSwitcher.keyboard?.mId?.isAlphabetShiftedManually != true) { + keyboardSwitcher.requestUpdatingShiftState(latinIME.currentAutoCapsState, latinIME.currentRecapitalizeState) + } + keyboardSwitcher.suggestionStripView?.updateToolbarButtonsActivatedState() + return + } KeyCode.TOGGLE_INCOGNITO_MODE -> { settings.toggleAlwaysIncognitoMode() // Invalidate keyboard to update spacebar incognito icon immediately diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 9613135af..a7ed92147 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -165,6 +165,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -245,6 +247,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close ToolbarKey.HANDWRITING -> R.drawable.ic_edit @@ -325,6 +329,8 @@ class KeyboardIconsSet private constructor() { ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad_rounded ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect_rounded + ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap + ToolbarKey.FORCE_AUTO_CAPS -> R.drawable.ic_force_auto_caps ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded ToolbarKey.HANDWRITING -> R.drawable.ic_edit diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index 056c083a3..a11e2033e 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -205,6 +205,8 @@ object KeyCode { const val CLEAR_HANDWRITING = -10075 const val SWITCH_TO_USER_IME = -10076 const val OCR = -10077 + const val TOGGLE_AUTO_CAP = -10079 + const val TOGGLE_FORCE_AUTO_CAPS = -10080 // Intents @@ -231,7 +233,7 @@ object KeyCode { SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, OCR, - CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME + CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME, TOGGLE_AUTO_CAP, TOGGLE_FORCE_AUTO_CAPS -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt index c9f00f4af..fa92de8d9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -131,6 +131,18 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang prefs.edit().putBoolean(PREF_AUTO_CORRECTION, !oldValue).apply() } + fun toggleAutoCapitalization() { + val prefs = mPrefs ?: return + val oldValue = prefs.getBoolean(PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) + prefs.edit().putBoolean(PREF_AUTO_CAP, !oldValue).apply() + } + + fun toggleForceAutoCapitalization() { + val prefs = mPrefs ?: return + val oldValue = prefs.getBoolean(PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) + prefs.edit().putBoolean(PREF_FORCE_AUTO_CAPS, !oldValue).apply() + } + fun toggleAlwaysIncognitoMode() { val prefs = mPrefs ?: return val oldValue = prefs.getBoolean(PREF_ALWAYS_INCOGNITO_MODE, Defaults.PREF_ALWAYS_INCOGNITO_MODE) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 451e78caf..2e71f453b 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -14,6 +14,7 @@ import android.os.Handler import android.os.Looper import android.annotation.SuppressLint import androidx.core.view.forEach +import androidx.core.view.ViewCompat import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.BuildConfig @@ -179,6 +180,8 @@ class TagDrawable(private val text: String) : Drawable() { fun setToolbarButtonsActivatedStateOnPrefChange(buttonsGroup: ViewGroup, key: String?) { // settings need to be updated when buttons change if (key != Settings.PREF_AUTO_CORRECTION + && key != Settings.PREF_AUTO_CAP + && key != Settings.PREF_FORCE_AUTO_CAPS && key != Settings.PREF_ALWAYS_INCOGNITO_MODE && key?.startsWith(Settings.PREF_ONE_HANDED_MODE_PREFIX) == false) return @@ -202,12 +205,26 @@ fun setToolbarButtonActivatedState(button: ImageButton) { ONE_HANDED -> Settings.getValues().mOneHandedModeEnabled SPLIT -> Settings.getValues().mIsSplitKeyboardEnabled AUTOCORRECT -> Settings.getValues().mAutoCorrectionEnabledPerUserSettings + AUTO_CAP -> Settings.getValues().mAutoCap + FORCE_AUTO_CAPS -> Settings.getValues().mAutoCap && Settings.getValues().mForceAutoCaps SELECT_MODE -> helium314.keyboard.keyboard.KeyboardActionListenerImpl.sPersistentSelectionModeActive else -> true } button.isActivated = activated + if (button.tag == AUTO_CAP || button.tag == FORCE_AUTO_CAPS) { + val prefs = button.context.prefs() + val saved = if (button.tag == AUTO_CAP) prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) + else prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) + val state = when { + !saved -> R.string.caps_control_off + activated -> R.string.caps_control_on + !prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP) -> R.string.caps_control_paused_auto_cap + else -> R.string.caps_control_no_uppercase + } + ViewCompat.setStateDescription(button, button.context.getString(state)) + } val colors = Settings.getValues().mColors - if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, SELECT_MODE)) { + if (activated && button.tag in listOf(INCOGNITO, ONE_HANDED, SPLIT, AUTOCORRECT, AUTO_CAP, FORCE_AUTO_CAPS, SELECT_MODE)) { colors.setColor(button.background, ColorType.TOOL_BAR_KEY_ENABLED_BACKGROUND) if (button.drawable != null) { button.clearColorFilter() @@ -243,6 +260,8 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool TOUCHPAD -> KeyCode.TOGGLE_TOUCHPAD_MODE TEXT_EDIT -> KeyCode.TOGGLE_TEXT_EDIT_MODE AUTOCORRECT -> KeyCode.TOGGLE_AUTOCORRECT + AUTO_CAP -> KeyCode.TOGGLE_AUTO_CAP + FORCE_AUTO_CAPS -> KeyCode.TOGGLE_FORCE_AUTO_CAPS CLEAR_CLIPBOARD -> KeyCode.CLIPBOARD_CLEAR_HISTORY CLOSE_HISTORY -> KeyCode.ALPHA EMOJI -> KeyCode.EMOJI @@ -299,7 +318,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, - INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, + INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, AUTO_CAP, FORCE_AUTO_CAPS, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, PROOFREAD, TRANSLATE, OCR, SELECT_MODE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10 diff --git a/app/src/main/res/drawable/ic_auto_cap.xml b/app/src/main/res/drawable/ic_auto_cap.xml new file mode 100644 index 000000000..683fe26c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_cap.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_force_auto_caps.xml b/app/src/main/res/drawable/ic_force_auto_caps.xml new file mode 100644 index 000000000..97b1fde76 --- /dev/null +++ b/app/src/main/res/drawable/ic_force_auto_caps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0891d1399..b2df3e71d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1555,6 +1555,11 @@ New dictionary: Touchpad mode Touchpad sensitivity Force auto-capitalization + @string/force_auto_caps_title + On + Off + Saved on; requires Auto-capitalization + Saved on; this language has no uppercase letters Force sentence capitalization on all text fields except passwords Variable toolbar direction diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt index 5af7ad7a4..679b58514 100644 --- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt +++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt @@ -3,6 +3,7 @@ package helium314.keyboard import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodSubtype +import androidx.core.content.edit import com.android.inputmethod.keyboard.ProximityInfo import helium314.keyboard.keyboard.Key import helium314.keyboard.keyboard.Key.KeyParams @@ -19,16 +20,22 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.POPUP_KEYS_NORMAL import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.LatinIME +import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.RichInputMethodSubtype 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.POPUP_KEYS_LAYOUT import helium314.keyboard.latin.utils.SubtypeUtilsAdditional +import helium314.keyboard.latin.utils.clearCustomToolbarKeyCodes +import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements @@ -51,6 +58,9 @@ class ParserTest { private lateinit var params: KeyboardParams @BeforeTest fun setUp() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions( + "${BuildConfig.APPLICATION_ID}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ) latinIME = Robolectric.setupService(LatinIME::class.java) ShadowLog.setupLogging() ShadowLog.stream = System.out @@ -154,6 +164,50 @@ f""", // no newline at the end assertIsExpected("""[[{ "label": "a" }]]""", Expected('a'.code, "a")) } + @Test @Config(sdk = [32, 35]) + fun capsControlsHaveKeywordNumericAndPopupActions() { + for ((label, code) in listOf("auto_cap" to -10079, "force_auto_caps" to -10080)) { + assertEquals(1, KeyCode::class.java.fields.count { + it.type == Int::class.javaPrimitiveType && it.getInt(null) == code + }, "$label must have a unique keycode") + assertIsExpected("""[[{"label":"$label"}]]""", Expected(code, icon = label)) + assertIsExpected("""[[{"label":"caps","code":$code}]]""", Expected(code, "caps")) + assertIsExpected("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""", + Expected('x'.code, "x", popups = listOf(null to code))) + val keyword = LayoutParser.parseJsonString("""[[{"label":"$label"}]]""") + .single().single().compute(params)!!.toKeyParams(params) + assertEquals(null, keyword.outputText) + val popup = LayoutParser.parseJsonString("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""") + .single().single().compute(params)!!.toKeyParams(params).mPopupKeys!!.single() + assertEquals(label, popup.mIconName) + assertEquals(null, popup.mOutputText) + } + assertIsExpected("""[[{"label":"force_auto_cap"}]]""", + Expected(KeyCode.MULTIPLE_CODE_POINTS, "force_auto_cap", text = "force_auto_cap")) + } + + @Test @Config(sdk = [32, 35]) + fun capsControlKeywordsRespectToolbarRemapsButNumericCodesStayFixed() { + val prefs = latinIME.prefs() + val original = prefs.getString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, null) + try { + prefs.edit { putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, "AUTO_CAP,-7,null;FORCE_AUTO_CAPS,-8,null") } + clearCustomToolbarKeyCodes() + for ((label, code, remapped) in listOf( + Triple("auto_cap", -10079, KeyCode.DELETE), + Triple("force_auto_caps", -10080, KeyCode.DELETE_WORD) + )) { + assertIsExpected("""[[{"label":"$label"}]]""", Expected(remapped, icon = label)) + assertIsExpected("""[[{"label":"caps","code":$code}]]""", Expected(code, "caps")) + assertIsExpected("""[[{"label":"x","popup":{"main":{"label":"$label"}}}]]""", + Expected('x'.code, "x", popups = listOf(null to remapped))) + } + } finally { + prefs.edit { putString(Settings.PREF_TOOLBAR_CUSTOM_KEY_CODES, original) } + clearCustomToolbarKeyCodes() + } + } + @Test fun labelAndExplicitCode() { assertIsExpected("""[[{ "$": "text_key" "label": "a", "code": 98 }]]""", Expected('b'.code, "a")) } diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index d9b5b2324..a099cc7b8 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -6,15 +6,27 @@ import android.os.Bundle import android.os.Handler import android.os.Message import android.text.InputType +import android.text.TextUtils import android.view.KeyEvent import android.view.inputmethod.* import androidx.core.content.edit +import androidx.core.view.ViewCompat import org.junit.Ignore import helium314.keyboard.ShadowInputMethodManager2 import helium314.keyboard.ShadowLocaleManagerCompat +import helium314.keyboard.ShadowProximityInfo +import helium314.keyboard.compat.AppQuirk +import helium314.keyboard.compat.AppQuirksManager import helium314.keyboard.event.Event import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.KeyboardTheme import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.internal.KeyboardIconsSet +import helium314.keyboard.keyboard.internal.KeyboardParams +import helium314.keyboard.keyboard.internal.KeyboardState import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.ShadowFacilitator2.Companion.addedWords import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastAddedWord @@ -22,20 +34,31 @@ import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastNgramContext import helium314.keyboard.latin.ShadowFacilitator2.Companion.ngramContexts import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.common.Constants.Separators import helium314.keyboard.latin.common.LocaleUtils.constructLocale import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.dictionary.Dictionary import helium314.keyboard.latin.inputlogic.InputLogic import helium314.keyboard.latin.inputlogic.SpaceState import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.suggestions.SuggestionStripView import helium314.keyboard.latin.utils.ScriptUtils import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.ToolbarKey +import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.getEnabledToolbarKeys +import helium314.keyboard.latin.utils.getPinnedToolbarKeys +import helium314.keyboard.latin.utils.getEnabledClipboardToolbarKeys +import helium314.keyboard.latin.utils.setToolbarButtonActivatedState +import helium314.keyboard.latin.utils.upgradeToolbarPrefs import helium314.keyboard.latin.utils.getTimestampFormatter import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith import org.mockito.Mockito import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements @@ -46,6 +69,9 @@ import kotlin.streams.asSequence import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @Config(shadows = [ @@ -72,6 +98,9 @@ class InputLogicTest { @BeforeTest fun setUp() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions( + "${BuildConfig.APPLICATION_ID}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ) latinIME = Robolectric.setupService(LatinIME::class.java) // start logging only after latinIME is created, avoids showing the stack traces if library is not found ShadowLog.setupLogging() @@ -89,6 +118,247 @@ class InputLogicTest { assertEquals("", composingText) } + @Test @Config(sdk = [32, 35]) + fun capsActionsToggleOnlyExistingPreferencesAndRefreshAutoShift() { + reset() + val state = Mockito.mock(KeyboardState::class.java) + val stateField = KeyboardSwitcher::class.java.getDeclaredField("mState").apply { isAccessible = true } + val originalState = stateField.get(latinIME.keyboardSwitcher) + stateField.set(latinIME.keyboardSwitcher, state) + try { + for ((code, pref, initial) in listOf( + Triple(-10079, Settings.PREF_AUTO_CAP, true), + Triple(-10080, Settings.PREF_FORCE_AUTO_CAPS, false) + )) { + val before = latinIME.prefs().all.toMap() + repeat(2) { pass -> + Mockito.clearInvocations(state) + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + val enabled = if (pass == 0) !initial else initial + assertEquals(before + (pref to enabled), latinIME.prefs().all) + assertEquals(if (pref == Settings.PREF_AUTO_CAP) enabled else true, settingsValues.mAutoCap) + assertEquals(if (pref == Settings.PREF_FORCE_AUTO_CAPS) enabled else false, settingsValues.mForceAutoCaps) + val caps = if (settingsValues.mAutoCap) TextUtils.CAP_MODE_SENTENCES else 0 + assertEquals(caps, inputLogic.getCurrentAutoCapsState(settingsValues)) + Mockito.verify(state).onUpdateShiftState(caps, null) + assertEquals("", text) + } + } + } finally { + stateField.set(latinIME.keyboardSwitcher, originalState) + } + } + + @Test @Config(sdk = [32, 35], shadows = [ShadowProximityInfo::class]) + fun capsActionsPreserveManualShiftAndCapsLock() { + reset() + val switcher = latinIME.keyboardSwitcher + val stateField = KeyboardSwitcher::class.java.getDeclaredField("mState").apply { isAccessible = true } + val viewField = KeyboardSwitcher::class.java.getDeclaredField("mKeyboardView").apply { isAccessible = true } + val originalState = stateField.get(switcher) + val originalView = viewField.get(switcher) + val state = Mockito.mock(KeyboardState::class.java) + val view = Mockito.mock(MainKeyboardView::class.java) + stateField.set(switcher, state) + viewField.set(switcher, view) + try { + for (element in listOf( + KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED + )) { + val keyboard = Keyboard(KeyboardParams().apply { + mId = KeyboardLayoutSet.getFakeKeyboardId(element) + GRID_WIDTH = 1 + GRID_HEIGHT = 1 + }) + Mockito.`when`(view.keyboard).thenReturn(keyboard) + for (code in listOf(-10079, -10080)) { + val before = latinIME.prefs().all.toMap() + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertTrue(before != latinIME.prefs().all) + assertEquals(keyboard, switcher.keyboard) + } + } + Mockito.verifyNoInteractions(state) + } finally { + stateField.set(switcher, originalState) + viewField.set(switcher, originalView) + } + } + + @Test @Config(sdk = [32, 35]) + fun capsActionsKeepCompositionSelectionAndPendingCorrectionUntouched() { + reset() + setText("keep ") + typeNoAssert("raw") + val correction = SuggestedWordInfo("RAW", "", 0, 0, Mockito.mock(Dictionary::class.java), 0, 0) + composer.setAutoCorrection(correction) + val before = Triple(text, cursor, composingText) + val wordsBefore = addedWords.toList() + for (code in listOf(-10079, -10080)) { + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertEquals(before, Triple(text, cursor, composingText)) + assertEquals(correction, composer.getAutoCorrectionOrNull()) + assertEquals(wordsBefore, addedWords) + checkConnectionConsistency() + } + setText("keep selected text") + setCursorPosition(5, 13) + for (code in listOf(-10079, -10080)) { + latinIME.keyboardActionListener.onCodeInput(code, 0, 0, false) + assertEquals("keep selected text", text) + assertEquals(5 to 13, selectionStart to selectionEnd) + assertEquals("selected", selectedText) + checkConnectionConsistency() + } + } + + @Test @Config(sdk = [32, 35]) + fun capsActionsStoreChoicesWithoutChangingAppAutocorrectOverrides() { + reset() + val packageName = "test.caps.controls" + val editorInfo = EditorInfo().apply { + inputType = currentInputType + this.packageName = packageName + } + editorInfoOverride = editorInfo + AppQuirksManager.saveQuirk(AppQuirk(packageName, autoCorrectionMode = AppQuirksManager.AUTOCORRECT_FORCE_ENABLE)) + try { + latinIME.onStartInputInternal(editorInfo, false) + latinIME.onStartInputViewInternal(editorInfo, false) + assertTrue(settingsValues.mAutoCorrectEnabled) + val before = latinIME.prefs().all.toMap() + latinIME.keyboardActionListener.onCodeInput(-10079, 0, 0, false) + latinIME.keyboardActionListener.onCodeInput(-10080, 0, 0, false) + assertEquals(before + mapOf(Settings.PREF_AUTO_CAP to false, Settings.PREF_FORCE_AUTO_CAPS to true), latinIME.prefs().all) + assertFalse(settingsValues.mAutoCap) + assertTrue(settingsValues.mForceAutoCaps) + assertTrue(settingsValues.mAutoCorrectEnabled) + assertEquals(0, inputLogic.getCurrentAutoCapsState(settingsValues)) + } finally { + AppQuirksManager.removeQuirk(packageName) + editorInfoOverride = null + } + } + + @Test @Config(sdk = [32, 35]) + fun capsToolbarAndPinnedControlsShareEffectiveStateAndDispatch() { + reset() + val auto = ToolbarKey.valueOf("AUTO_CAP") + val force = ToolbarKey.valueOf("FORCE_AUTO_CAPS") + val strip = SuggestionStripView(latinIME, null) + val keyboardView = MainKeyboardView(latinIME, null).apply { id = R.id.keyboard_view } + strip.setListener(latinIME, keyboardView) + val imeStripField = LatinIME::class.java.getDeclaredField("suggestionStripView").apply { isAccessible = true } + val switcherStripField = KeyboardSwitcher::class.java.getDeclaredField("mSuggestionStripView").apply { isAccessible = true } + val originalImeStrip = imeStripField.get(latinIME) + val originalSwitcherStrip = switcherStripField.get(latinIME.keyboardSwitcher) + imeStripField.set(latinIME, strip) + switcherStripField.set(latinIME.keyboardSwitcher, strip) + try { + val toolbar = strip.findViewById(R.id.toolbar) + val pinned = strip.findViewById(R.id.pinned_keys) + toolbar.removeAllViews() + pinned.removeAllViews() + val mainAuto = createToolbarKey(latinIME, auto) + val pinnedAuto = createToolbarKey(latinIME, auto) + val mainForce = createToolbarKey(latinIME, force) + val pinnedForce = createToolbarKey(latinIME, force) + listOf(mainAuto, mainForce).forEach { toolbar.addView(it); it.setOnClickListener(strip) } + listOf(pinnedAuto, pinnedForce).forEach { pinned.addView(it); it.setOnClickListener(strip) } + assertEquals("Auto-capitalization", mainAuto.contentDescription) + assertEquals("Force auto-capitalization", mainForce.contentDescription) + assertTrue(mainAuto.isActivated) + assertFalse(mainForce.isActivated) + assertEquals("On", ViewCompat.getStateDescription(mainAuto)) + assertEquals("Off", ViewCompat.getStateDescription(mainForce)) + mainForce.performClick() + assertTrue(mainForce.isActivated) + assertTrue(pinnedForce.isActivated) + pinnedAuto.performClick() + assertFalse(mainAuto.isActivated) + assertFalse(pinnedAuto.isActivated) + assertFalse(mainForce.isActivated) + assertFalse(pinnedForce.isActivated) + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_FORCE_AUTO_CAPS, false)) + assertEquals("Saved on; requires Auto-capitalization", ViewCompat.getStateDescription(mainForce)) + assertEquals(ViewCompat.getStateDescription(mainForce), ViewCompat.getStateDescription(pinnedForce)) + mainAuto.performClick() + assertTrue(mainAuto.isActivated) + assertTrue(pinnedAuto.isActivated) + assertTrue(mainForce.isActivated) + assertTrue(pinnedForce.isActivated) + assertEquals("On", ViewCompat.getStateDescription(pinnedForce)) + } finally { + imeStripField.set(latinIME, originalImeStrip) + switcherStripField.set(latinIME.keyboardSwitcher, originalSwitcherStrip) + } + } + + @Test @Config(sdk = [32, 35]) + fun capsControlsHaveIconsInEveryStyleAndRespectUncasedLocales() { + reset() + val keys = listOf(ToolbarKey.valueOf("AUTO_CAP"), ToolbarKey.valueOf("FORCE_AUTO_CAPS")) + for (style in listOf(KeyboardTheme.STYLE_HOLO, KeyboardTheme.STYLE_MATERIAL, KeyboardTheme.STYLE_ROUNDED)) { + latinIME.prefs().edit { putString(Settings.PREF_ICON_STYLE, style) } + KeyboardIconsSet.instance.loadIcons(latinIME) + keys.forEach { assertNotNull(createToolbarKey(latinIME, it).drawable) } + } + latinIME.prefs().edit { putBoolean(Settings.PREF_FORCE_AUTO_CAPS, true) } + latinIME.settings.loadSettings(latinIME, Locale.KOREAN, settingsValues.mInputAttributes, ScriptUtils.SCRIPT_LATIN) + assertFalse(settingsValues.mAutoCap) + keys.forEach { + val button = createToolbarKey(latinIME, it) + assertFalse(button.isActivated) + assertEquals("Saved on; this language has no uppercase letters", ViewCompat.getStateDescription(button)) + } + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_AUTO_CAP, true)) + assertTrue(latinIME.prefs().getBoolean(Settings.PREF_FORCE_AUTO_CAPS, false)) + } + + @Test @Config(sdk = [32, 35]) + fun capsControlsUpgradeWithoutChangingToolbarOrderOrEnablingNewItems() { + reset() + val keys = listOf(ToolbarKey.valueOf("AUTO_CAP"), ToolbarKey.valueOf("FORCE_AUTO_CAPS")) + assertTrue(keys.none { it in getEnabledToolbarKeys(latinIME.prefs()) || it in getPinnedToolbarKeys(latinIME.prefs()) }) + val enabled = listOf(ToolbarKey.PASTE, ToolbarKey.AUTOCORRECT) + val oldKeys = enabled + ToolbarKey.entries.filter { it !in keys && it !in enabled } + val original = oldKeys.joinToString(Separators.ENTRY) { it.name + Separators.KV + (it in enabled) } + val prefs = listOf(Settings.PREF_TOOLBAR_KEYS, Settings.PREF_PINNED_TOOLBAR_KEYS, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS) + latinIME.prefs().edit { prefs.forEach { putString(it, original) } } + upgradeToolbarPrefs(latinIME.prefs()) + assertEquals(enabled, getEnabledToolbarKeys(latinIME.prefs())) + assertEquals(enabled, getPinnedToolbarKeys(latinIME.prefs())) + assertEquals(enabled, getEnabledClipboardToolbarKeys(latinIME.prefs())) + for (pref in prefs) { + val updated = latinIME.prefs().getString(pref, "")!! + assertTrue(updated.startsWith(original)) + keys.forEach { assertTrue(updated.contains(it.name + Separators.KV + false)) } + } + } + + @Test @Config(sdk = [32, 35]) + fun capsControlHighlightUsesEffectiveEnabledColors() { + reset() + latinIME.prefs().edit { putBoolean(Settings.PREF_FORCE_AUTO_CAPS, true) } + val values = settingsValues + val colorsField = values.javaClass.getDeclaredField("mColors").apply { isAccessible = true } + val originalColors = colorsField.get(values) + val colors = Mockito.mock(helium314.keyboard.latin.common.Colors::class.java) + val buttons = listOf("AUTO_CAP", "FORCE_AUTO_CAPS").map { createToolbarKey(latinIME, ToolbarKey.valueOf(it)) } + colorsField.set(values, colors) + try { + for (button in buttons) { + setToolbarButtonActivatedState(button) + Mockito.verify(colors).setColor(button.background, helium314.keyboard.latin.common.ColorType.TOOL_BAR_KEY_ENABLED_BACKGROUND) + Mockito.clearInvocations(colors) + } + } finally { + colorsField.set(values, originalColors) + } + } + @Test fun `english space-separated typing keeps composing word`() { reset() chainInput("hello") @@ -1265,6 +1535,7 @@ class InputLogicTest { text = "" batchEdit = 0 currentInputType = InputType.TYPE_CLASS_TEXT + editorInfoOverride = null lastAddedWord = "" lastNgramContext = "" addedWords.clear() @@ -1506,6 +1777,7 @@ class InputLogicTest { } private var currentInputType = InputType.TYPE_CLASS_TEXT +private var editorInfoOverride: EditorInfo? = null private var currentScript = ScriptUtils.SCRIPT_LATIN private val messages = mutableListOf() // for latinIME / ShadowInputMethodService private val delayedMessages = mutableListOf() // for latinIME / ShadowInputMethodService @@ -1705,7 +1977,7 @@ private val ic = object : InputConnection { @Implements(InputMethodService::class) class ShadowInputMethodService { @Implementation - fun getCurrentInputEditorInfo() = EditorInfo().apply { + fun getCurrentInputEditorInfo() = editorInfoOverride ?: EditorInfo().apply { inputType = currentInputType // anything else? } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 779455ee9..7b69e6cfe 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -645,6 +645,25 @@ LeanType provides an extensible keycode architecture that allows customizing key > [!TIP] > All negative keycodes listed below are actively recognized and validated by `KeyCode.checkAndConvertCode()`. Positive integer codes correspond to standard Unicode codepoints (or ASCII values like `10` for Enter, `32` for Space, `9` for Tab). +### Capitalization controls + +**Auto-capitalization** (`{"label":"auto_cap"}`, fixed code `-10079`) and +**Force auto-capitalization** (`{"label":"force_auto_caps"}`, fixed code `-10080`) +toggle the existing saved Text correction settings. Enable these optional controls +in the toolbar, pin them, or use them in custom JSON keys and popups. New entries +start disabled; existing toolbar order and custom keycodes are preserved. +Keywords follow customized toolbar codes; an explicit numeric `code` keeps the +fixed action. The force keyword is plural: `force_auto_caps`. + +These settings request sentence capitalization, not manual Shift or Caps Lock. +Toggling preserves manual Shift/Caps Lock, selection, and the current composing +word. Highlights reflect effective enablement, not whether the next character +will be uppercase. Turning Auto-capitalization off also suppresses Force +auto-capitalization without clearing its saved choice. Languages without +uppercase letters suppress both effects. Accessibility state descriptions +distinguish off, on, and saved-on-but-inactive states. Existing field-capitalization +and correction rules are unchanged. + --- ### 1. ✏️ Text Editing & Deletion @@ -752,6 +771,8 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-233`** | `VOICE_INPUT` | Initiates voice typing (Whisper on-device or system voice IME). | | **`-244`** | `TOGGLE_INCOGNITO_MODE`| Toggles incognito private mode (disables dictionary learning). | | **`-245`** | `TOGGLE_AUTOCORRECT` | Instantly toggles auto-correction on or off. | +| **`-10079`** | `TOGGLE_AUTO_CAP` | Toggles the saved Auto-capitalization setting. | +| **`-10080`** | `TOGGLE_FORCE_AUTO_CAPS` | Toggles the saved Force auto-capitalization setting. | | **`-301`** | `SETTINGS` | Opens LeanType Settings. | | **`-10043`** | `TIMESTAMP` | Inserts the current localized date and time stamp at cursor. | | **`-10051`** | `INLINE_EMOJI_SEARCH_DONE` | Confirms and closes inline emoji search. | @@ -830,4 +851,3 @@ LeanType provides an extensible keycode architecture that allows customizing key | **`-10008`** | `NOT_SPECIFIED` | Structural dummy spacer key (disables key interaction, renders blank). | -