From da455720a615b2a4e3a9917f5460a89c806ca9c1 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Sun, 12 Jul 2026 19:37:17 +0300 Subject: [PATCH 1/5] release: prepare LeanTypeDual 3.11.0 --- CHANGELOG.md | 18 +++++++++- app/build.gradle.kts | 4 +-- .../main/assets/dictionaries_in_dict_repo.csv | 12 +++++-- docs/releasenote/release_notes_v3.11.0.md | 20 +++++++++++ .../android/en-US/changelogs/4100.txt | 5 +++ tools/release.py | 4 +-- tools/test_release.py | 34 +++++++++++++++++++ 7 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 docs/releasenote/release_notes_v3.11.0.md create mode 100644 fastlane/metadata/android/en-US/changelogs/4100.txt create mode 100644 tools/test_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f708f0a..82fa1c911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,27 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [3.11.0] - 2026-07-12 + +### Added +- **Direct IME switching** — configure a target keyboard/subtype and map keycode `-10076` to a toolbar action for immediate switching without the system picker. +- **Five persistent custom layout slots** — custom layouts now restore correctly across symbol mode, orientation changes, and keyboard reloads. +- **Suggestion controls** — configure auto-correct trigger characters and optionally suppress multi-word suggestions. + +### Changed +- **Built-in Java gesture typing** uses less memory, streams dictionary entries, and improves path scoring/ranking performance. +- **Text Expander placeholder handling** now resolves and advances placeholders synchronously to avoid cursor/selection desynchronization. + ### Fixed - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) +- **Dictionary and blacklist handling** prevents blocked words from leaking back into gesture and normal suggestions. + +### Reliability & testing +- Added regression coverage for KeyCode uniqueness, custom-layout state restoration, direct IME switch branches, multi-word filtering, and fallback gesture casing. +- Fixed the Windows release tool to read dictionary metadata as UTF-8. ### Upstream -- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4) — adds direct switching to a configured IME, persistent custom-layout state, multi-word suggestion controls, and the v3.9.5 release notes. Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. +- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4). Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. ## [3.10.0] - 2026-06-20 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7f6199d8..a79857987 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.asafmah.leantypedual" minSdk = 21 targetSdk = 35 - versionCode = 4000 - versionName = "3.10.0" + versionCode = 4100 + versionName = "3.11.0" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/app/src/main/assets/dictionaries_in_dict_repo.csv b/app/src/main/assets/dictionaries_in_dict_repo.csv index e41b6ffcc..f00b45d46 100644 --- a/app/src/main/assets/dictionaries_in_dict_repo.csv +++ b/app/src/main/assets/dictionaries_in_dict_repo.csv @@ -14,10 +14,10 @@ emoji,as,cldr main,as, emoji,ast,cldr emoji,az,cldr -main,bn_BD,exp +main,az, emoji,bn,cldr main,bn, -main,bn,exp +main,bn2, emoji,eu,cldr main,eu, emoji,be,cldr @@ -62,8 +62,8 @@ main,en_GB, main,en_GB,exp main,en_US, main,en_US,exp -symbols,en,exp emoji,en, +symbols,en,exp emoji,en,cldr emoji,eo,cldr main,eo, @@ -119,6 +119,7 @@ emoji,id,cldr main,id,exp emoji,ia,cldr emoji,ga,cldr +main,ga, emoji,it,cldr main,it, main,it,exp @@ -155,6 +156,7 @@ main,lb, emoji,mk,cldr main,mk, main,mai, +main,mg, emoji,ms,cldr addon,ml_ZZ,exp emoji,ml,cldr @@ -164,6 +166,7 @@ emoji,mni,cldr emoji,mr,cldr main,mr, main,mwl, +main,mwl,exp emoji,mn,cldr emoji,mi,cldr emoji,ne,cldr @@ -218,6 +221,7 @@ emoji,sd,cldr main,sd, emoji,si,cldr emoji,sk,cldr +main,sk, main,sk,exp emoji,sl,cldr main,sl, @@ -240,6 +244,7 @@ main,ta, emoji,te,cldr main,te, emoji,th,cldr +main,th, emoji,ti,cldr main,tok, emoji,to,cldr @@ -247,6 +252,7 @@ emoji,tn,cldr main,tcy, emoji,tr,cldr main,tr, +main,tr2, main,tr,exp emoji,tk,cldr emoji,uk, diff --git a/docs/releasenote/release_notes_v3.11.0.md b/docs/releasenote/release_notes_v3.11.0.md new file mode 100644 index 000000000..b620f9153 --- /dev/null +++ b/docs/releasenote/release_notes_v3.11.0.md @@ -0,0 +1,20 @@ +# LeanTypeDual 3.11.0 + +## Highlights + +- Configure a target input method and switch directly to it using toolbar keycode `-10076`. +- Create and persist up to five custom layout slots across symbol mode, rotation, and keyboard reloads. +- Configure auto-correct trigger characters and optionally suppress multi-word suggestions. +- Use the optimized built-in Java gesture engine with lower memory usage, improved ranking, and corrected suggestion casing. +- Benefit from more reliable Text Expander placeholder navigation and dictionary/blacklist handling. + +## Build variants + +- **Standard Full**: cloud AI and handwriting support; requires Internet permission. +- **Standard**: FOSS standard build; requires Internet permission for opt-in online features and downloads. +- **Offline**: on-device AI; no Internet permission. +- **Offline Lite**: smallest build without AI integration; no Internet permission. + +## Upgrade notes + +LeanTypeDual remains a separate application with the same package identity. Existing settings and dictionaries are retained when upgrading. diff --git a/fastlane/metadata/android/en-US/changelogs/4100.txt b/fastlane/metadata/android/en-US/changelogs/4100.txt new file mode 100644 index 000000000..32d7a3548 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4100.txt @@ -0,0 +1,5 @@ +- Switch directly to a configured keyboard and subtype. +- Save and restore up to five custom layout slots. +- Configure auto-correct triggers and hide multi-word suggestions. +- Faster, lower-memory built-in gesture typing with corrected suggestion casing. +- Improved Text Expander, dictionary, and blacklist reliability. diff --git a/tools/release.py b/tools/release.py index c26c447e3..b14002d83 100755 --- a/tools/release.py +++ b/tools/release.py @@ -66,14 +66,14 @@ def check_default_values_diff(): def read_dicts_readme() -> list[str]: dicts_readme_file = "../dictionaries/README.md" if os.path.isfile(dicts_readme_file): - f = open(dicts_readme_file) + f = open(dicts_readme_file, encoding="utf-8") lines = f.readlines() f.close() return lines readme_url = "https://codeberg.org/Helium314/aosp-dictionaries/raw/branch/main/README.md" tmp_readme = "dicts_readme_tmp.md" urlretrieve(readme_url, tmp_readme) - f = open(tmp_readme) + f = open(tmp_readme, encoding="utf-8") lines = f.readlines() f.close() os.remove(tmp_readme) diff --git a/tools/test_release.py b/tools/test_release.py new file mode 100644 index 000000000..aee664d7f --- /dev/null +++ b/tools/test_release.py @@ -0,0 +1,34 @@ +import builtins +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tools import release + + +class ReleaseToolTest(unittest.TestCase): + def test_local_dictionary_readme_is_read_as_utf8(self): + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + # U+0181 encodes to byte 0x81 in UTF-8's continuation position; Windows cp1252 + # rejects that byte when the file is opened without an explicit UTF-8 encoding. + readme.write_text("# Dictionaries\n| Ɓengali | [dict](main_bn.dict) |\n", encoding="utf-8") + real_open = builtins.open + + def redirect_open(path, *args, **kwargs): + if path == "../dictionaries/README.md": + if kwargs.get("encoding", "").lower() != "utf-8": + raise UnicodeDecodeError("charmap", b"\x81", 0, 1, "undefined") + return real_open(readme, *args, **kwargs) + return real_open(path, *args, **kwargs) + + with patch.object(release.os.path, "isfile", return_value=True), \ + patch("builtins.open", side_effect=redirect_open): + lines = release.read_dicts_readme() + + self.assertIn("Ɓengali", "".join(lines)) + + +if __name__ == "__main__": + unittest.main() From 3163d7597b15ff2efd09d613c1ed7e2ff9d73300 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Sun, 12 Jul 2026 19:45:36 +0300 Subject: [PATCH 2/5] docs: add 3.11.0 changelog references --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fa1c911..016a3b314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,24 +17,24 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [3.11.0] - 2026-07-12 ### Added -- **Direct IME switching** — configure a target keyboard/subtype and map keycode `-10076` to a toolbar action for immediate switching without the system picker. -- **Five persistent custom layout slots** — custom layouts now restore correctly across symbol mode, orientation changes, and keyboard reloads. -- **Suggestion controls** — configure auto-correct trigger characters and optionally suppress multi-word suggestions. +- **Direct IME switching** — configure a target keyboard/subtype and map keycode `-10076` to a toolbar action for immediate switching without the system picker. (#118) +- **Five persistent custom layout slots** — custom layouts now restore correctly across symbol mode, orientation changes, and keyboard reloads. (#118) +- **Suggestion controls** — configure auto-correct trigger characters and optionally suppress multi-word suggestions. (#118) ### Changed -- **Built-in Java gesture typing** uses less memory, streams dictionary entries, and improves path scoring/ranking performance. -- **Text Expander placeholder handling** now resolves and advances placeholders synchronously to avoid cursor/selection desynchronization. +- **Built-in Java gesture typing** uses less memory, streams dictionary entries, and improves path scoring/ranking performance. (#118) +- **Text Expander placeholder handling** now resolves and advances placeholders synchronously to avoid cursor/selection desynchronization. (#118) ### Fixed - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) -- **Dictionary and blacklist handling** prevents blocked words from leaking back into gesture and normal suggestions. +- **Dictionary and blacklist handling** prevents blocked words from leaking back into gesture and normal suggestions. (#118) ### Reliability & testing -- Added regression coverage for KeyCode uniqueness, custom-layout state restoration, direct IME switch branches, multi-word filtering, and fallback gesture casing. -- Fixed the Windows release tool to read dictionary metadata as UTF-8. +- Added regression coverage for KeyCode uniqueness, custom-layout state restoration, direct IME switch branches, multi-word filtering, and fallback gesture casing. (#118) +- Fixed the Windows release tool to read dictionary metadata as UTF-8. (#119) ### Upstream -- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4). Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. +- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4). Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. (#118) ## [3.10.0] - 2026-06-20 From f6ae2372031a186f793a5cbdc3bb81fd33e01548 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Sun, 12 Jul 2026 19:56:41 +0300 Subject: [PATCH 3/5] docs: note refreshed dictionary catalog --- CHANGELOG.md | 1 + docs/releasenote/release_notes_v3.11.0.md | 1 + fastlane/metadata/android/en-US/changelogs/4100.txt | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 016a3b314..2c0e550b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed - **Built-in Java gesture typing** uses less memory, streams dictionary entries, and improves path scoring/ranking performance. (#118) - **Text Expander placeholder handling** now resolves and advances placeholders synchronously to avoid cursor/selection desynchronization. (#118) +- **Dictionary download catalog** is refreshed to the current repository inventory, removing stale unavailable entries and adding newly published dictionaries. (#119) ### Fixed - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) diff --git a/docs/releasenote/release_notes_v3.11.0.md b/docs/releasenote/release_notes_v3.11.0.md index b620f9153..123bb9d8e 100644 --- a/docs/releasenote/release_notes_v3.11.0.md +++ b/docs/releasenote/release_notes_v3.11.0.md @@ -6,6 +6,7 @@ - Create and persist up to five custom layout slots across symbol mode, rotation, and keyboard reloads. - Configure auto-correct trigger characters and optionally suppress multi-word suggestions. - Use the optimized built-in Java gesture engine with lower memory usage, improved ranking, and corrected suggestion casing. +- Browse an updated dictionary download catalog with stale unavailable entries removed and newly published dictionaries added. - Benefit from more reliable Text Expander placeholder navigation and dictionary/blacklist handling. ## Build variants diff --git a/fastlane/metadata/android/en-US/changelogs/4100.txt b/fastlane/metadata/android/en-US/changelogs/4100.txt index 32d7a3548..7a4e04325 100644 --- a/fastlane/metadata/android/en-US/changelogs/4100.txt +++ b/fastlane/metadata/android/en-US/changelogs/4100.txt @@ -2,4 +2,4 @@ - Save and restore up to five custom layout slots. - Configure auto-correct triggers and hide multi-word suggestions. - Faster, lower-memory built-in gesture typing with corrected suggestion casing. -- Improved Text Expander, dictionary, and blacklist reliability. +- Updated the dictionary download catalog and improved Text Expander and blacklist reliability. From 23578dbe3995955ca19583ce5f95b49d69ad16fc Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Sun, 12 Jul 2026 20:38:53 +0300 Subject: [PATCH 4/5] fix(ime): support direct switching before Android 9 --- CHANGELOG.md | 1 + .../helium314/keyboard/compat/ImeCompat.kt | 14 ++- .../helium314/keyboard/latin/LatinIME.java | 2 +- .../test/java/helium314/keyboard/Shadows.kt | 24 +++++ .../keyboard/compat/ImeCompatTest.kt | 99 +++++++++++++++++++ docs/releasenote/release_notes_v3.11.0.md | 2 +- .../android/en-US/changelogs/4100.txt | 2 +- 7 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0e550b1..d2cd9ce2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Dictionary download catalog** is refreshed to the current repository inventory, removing stale unavailable entries and adding newly published dictionaries. (#119) ### Fixed +- **Direct IME switching on Android 6–8** now uses the legacy input-method manager API instead of calling an Android 9+ framework method. (#119) - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) - **Dictionary and blacklist handling** prevents blocked words from leaking back into gesture and normal suggestions. (#118) diff --git a/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt b/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt index 8dc5421f7..c6e6efedd 100644 --- a/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt +++ b/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt @@ -14,7 +14,7 @@ object ImeCompat { fun InputMethodService.switchInputMethod(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return switchToNextInputMethod(false) val window = window.window ?: return false - val token = window.attributes.token + val token = window.attributes.token ?: return false return RichInputMethodManager.getInstance().inputMethodManager.switchToNextInputMethod(token, false) } @@ -26,12 +26,22 @@ object ImeCompat { return RichInputMethodManager.getInstance().inputMethodManager.shouldOfferSwitchingToNextInputMethod(token) } + fun InputMethodService.switchToInputMethod(imi: InputMethodInfo) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + switchInputMethod(imi.id) + } else { + val window = window.window ?: return + val token = window.attributes.token ?: return + RichInputMethodManager.getInstance().inputMethodManager.setInputMethod(token, imi.id) + } + } + fun InputMethodService.switchInputMethodAndSubtype(imi: InputMethodInfo, subtype: InputMethodSubtype) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { switchInputMethod(imi.id, subtype) } else { val window = window.window ?: return - val token = window.attributes.token + val token = window.attributes.token ?: return RichInputMethodManager.getInstance().inputMethodManager.setInputMethodAndSubtype(token, imi.id, subtype) } } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index d445b25ad..da4e3f50c 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1585,7 +1585,7 @@ public void switchToUserIme() { } else if (targetSubtype != null) { ImeCompat.INSTANCE.switchInputMethodAndSubtype(this, targetImi, targetSubtype); } else { - switchInputMethod(targetImi.getId()); + ImeCompat.INSTANCE.switchToInputMethod(this, targetImi); } } diff --git a/app/src/test/java/helium314/keyboard/Shadows.kt b/app/src/test/java/helium314/keyboard/Shadows.kt index 37675018f..3e2a576fd 100644 --- a/app/src/test/java/helium314/keyboard/Shadows.kt +++ b/app/src/test/java/helium314/keyboard/Shadows.kt @@ -41,6 +41,24 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() { @Implementation fun getShortcutInputMethodsAndSubtypes() = emptyMap>() + @Implementation + fun switchToNextInputMethod(token: android.os.IBinder?, onlyCurrentIme: Boolean): Boolean { + switchedToNextInputMethod = true + return true + } + + @Implementation + fun setInputMethod(token: android.os.IBinder?, id: String) { + switchedImeId = id + switchedSubtype = null + } + + @Implementation + fun setInputMethodAndSubtype(token: android.os.IBinder?, id: String, subtype: InputMethodSubtype) { + switchedImeId = id + switchedSubtype = subtype + } + companion object { private fun defaultInputMethod() = InputMethodInfo( BuildConfig.APPLICATION_ID, @@ -51,10 +69,16 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() { var inputMethods: List = listOf(defaultInputMethod()) val enabledSubtypes = mutableMapOf>() + var switchedImeId: String? = null + var switchedSubtype: InputMethodSubtype? = null + var switchedToNextInputMethod = false fun reset() { inputMethods = listOf(defaultInputMethod()) enabledSubtypes.clear() + switchedImeId = null + switchedSubtype = null + switchedToNextInputMethod = false } } } diff --git a/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt b/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt new file mode 100644 index 000000000..5bfd8e527 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt @@ -0,0 +1,99 @@ +package helium314.keyboard.compat + +import android.app.Dialog +import android.content.Context +import android.inputmethodservice.InputMethodService +import android.os.Binder +import android.view.Window +import android.view.WindowManager +import android.view.inputmethod.InputMethodInfo +import android.view.inputmethod.InputMethodSubtype +import androidx.test.core.app.ApplicationProvider +import helium314.keyboard.ShadowInputMethodManager2 +import helium314.keyboard.latin.RichInputMethodManager +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [27], shadows = [ShadowInputMethodManager2::class]) +class ImeCompatTest { + @BeforeTest + fun setUp() { + ShadowInputMethodManager2.reset() + RichInputMethodManager.init(ApplicationProvider.getApplicationContext()) + } + + @Test + fun preAndroidPDirectImeSwitchUsesInputMethodManager() { + val service = serviceWithWindowToken() + + ImeCompat.run { service.switchToInputMethod(EXTERNAL_IME) } + + assertEquals(EXTERNAL_IME.id, ShadowInputMethodManager2.switchedImeId) + assertNull(ShadowInputMethodManager2.switchedSubtype) + } + + @Test + fun preAndroidPNextImeWithoutWindowTokenIsNoOp() { + val service = serviceWithWindowToken(null) + + val switched = ImeCompat.run { service.switchInputMethod() } + + assertEquals(false, switched) + assertEquals(false, ShadowInputMethodManager2.switchedToNextInputMethod) + } + + @Test + fun preAndroidPWithoutWindowTokenIsNoOp() { + val service = serviceWithWindowToken(null) + + ImeCompat.run { service.switchToInputMethod(EXTERNAL_IME) } + ImeCompat.run { service.switchInputMethodAndSubtype(EXTERNAL_IME, EXTERNAL_SUBTYPE) } + + assertNull(ShadowInputMethodManager2.switchedImeId) + assertNull(ShadowInputMethodManager2.switchedSubtype) + } + + @Test + fun preAndroidPDirectImeSubtypeSwitchUsesInputMethodManager() { + val service = serviceWithWindowToken() + + ImeCompat.run { service.switchInputMethodAndSubtype(EXTERNAL_IME, EXTERNAL_SUBTYPE) } + + assertEquals(EXTERNAL_IME.id, ShadowInputMethodManager2.switchedImeId) + assertEquals(EXTERNAL_SUBTYPE, ShadowInputMethodManager2.switchedSubtype) + } + + private fun serviceWithWindowToken(token: android.os.IBinder? = Binder()): InputMethodService { + val service = mock(InputMethodService::class.java) + val dialog = mock(Dialog::class.java) + val window = mock(Window::class.java) + val attributes = WindowManager.LayoutParams().apply { this.token = token } + `when`(service.window).thenReturn(dialog) + `when`(dialog.window).thenReturn(window) + `when`(window.attributes).thenReturn(attributes) + return service + } + + companion object { + private val EXTERNAL_IME = InputMethodInfo( + "example.ime", + "example.ime.Service", + "Example IME", + null, + ) + private val EXTERNAL_SUBTYPE: InputMethodSubtype = InputMethodSubtype.InputMethodSubtypeBuilder() + .setSubtypeId(202) + .setLanguageTag("fr-FR") + .setSubtypeLocale("fr_FR") + .setSubtypeMode("keyboard") + .build() + } +} diff --git a/docs/releasenote/release_notes_v3.11.0.md b/docs/releasenote/release_notes_v3.11.0.md index 123bb9d8e..9bcee01eb 100644 --- a/docs/releasenote/release_notes_v3.11.0.md +++ b/docs/releasenote/release_notes_v3.11.0.md @@ -2,7 +2,7 @@ ## Highlights -- Configure a target input method and switch directly to it using toolbar keycode `-10076`. +- Configure a target input method and switch directly to it using toolbar keycode `-10076`, including on Android 6–8. - Create and persist up to five custom layout slots across symbol mode, rotation, and keyboard reloads. - Configure auto-correct trigger characters and optionally suppress multi-word suggestions. - Use the optimized built-in Java gesture engine with lower memory usage, improved ranking, and corrected suggestion casing. diff --git a/fastlane/metadata/android/en-US/changelogs/4100.txt b/fastlane/metadata/android/en-US/changelogs/4100.txt index 7a4e04325..90d89a7fc 100644 --- a/fastlane/metadata/android/en-US/changelogs/4100.txt +++ b/fastlane/metadata/android/en-US/changelogs/4100.txt @@ -1,4 +1,4 @@ -- Switch directly to a configured keyboard and subtype. +- Switch directly to a configured keyboard and subtype, including on Android 6–8. - Save and restore up to five custom layout slots. - Configure auto-correct triggers and hide multi-word suggestions. - Faster, lower-memory built-in gesture typing with corrected suggestion casing. From fb071ffb826c19d9c7105a726a6d2e97623beb10 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Tue, 14 Jul 2026 23:14:39 +0300 Subject: [PATCH 5/5] fix(suggestions): preserve unshifted lowercase words --- CHANGELOG.md | 2 ++ .../java/helium314/keyboard/latin/Suggest.kt | 12 +++++++++ .../keyboard/latin/common/StringUtils.java | 25 +++++++++++++++++++ .../keyboard/latin/inputlogic/InputLogic.java | 18 ++++++++----- .../keyboard/latin/InputLogicTest.kt | 24 ++++++++++++++++++ .../keyboard/latin/StringUtilsTest.kt | 20 +++++++++++++++ .../helium314/keyboard/latin/SuggestTest.kt | 15 +++++++++++ docs/releasenote/release_notes_v3.11.0.md | 1 + .../android/en-US/changelogs/4100.txt | 1 + 9 files changed, 112 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cd9ce2d..b94ed0604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed - **Direct IME switching on Android 6–8** now uses the legacy input-method manager API instead of calling an Android 9+ framework method. (#119) +- **Unshifted typing and swiping preserve lowercase words** instead of promoting ordinary words such as `to`, `no`, and `meet` to title-case dictionary candidates. (#120) - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) - **Dictionary and blacklist handling** prevents blocked words from leaking back into gesture and normal suggestions. (#118) ### Reliability & testing - Added regression coverage for KeyCode uniqueness, custom-layout state restoration, direct IME switch branches, multi-word filtering, and fallback gesture casing. (#118) +- Added tap, batch-commit, shift-mode, acronym, mixed-case, and Unicode regression coverage for suggestion casing. (#120) - Fixed the Windows release tool to read dictionary metadata as UTF-8. (#119) ### Upstream diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt index 0c0244894..b067044f0 100644 --- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt +++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt @@ -235,6 +235,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { // i guess then not mAutoCorrectionEnabledPerUserSettings should be read, but rather some isAutocorrectEnabled() // 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 + || isUnrequestedTitleCaseCorrection(typedWordString, firstSuggestionInContainer) || !wordComposer.isComposingWord // 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 @@ -319,6 +320,17 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { return allowsToBeAutoCorrected to hasAutoCorrection } + private fun isUnrequestedTitleCaseCorrection( + typedWord: String, + firstSuggestion: SuggestedWordInfo?, + ): Boolean { + val suggestion = firstSuggestion?.mWord ?: return false + return StringUtils.hasAtLeastTwoLetters(typedWord) + && StringUtils.isIdenticalAfterDowncase(typedWord) + && StringUtils.getCapitalizationType(suggestion) == StringUtils.CAPITALIZE_FIRST + && typedWord.equals(suggestion, ignoreCase = true) + } + /** * For long words (>6 chars), a correction candidate deserves a bonus because * a single typo in a 12-char word is proportionally less significant than in a 4-char word. diff --git a/app/src/main/java/helium314/keyboard/latin/common/StringUtils.java b/app/src/main/java/helium314/keyboard/latin/common/StringUtils.java index c92b215e8..b66c6ab53 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/StringUtils.java +++ b/app/src/main/java/helium314/keyboard/latin/common/StringUtils.java @@ -77,6 +77,21 @@ public static String capitalizeFirstCodePoint(@NonNull final String s, + s.substring(cutoff); } + @NonNull + public static String lowercaseFirstLetterCodePoint(@NonNull final String s, + @NonNull final Locale locale) { + for (int index = 0; index < s.length(); index = s.offsetByCodePoints(index, 1)) { + final int codePoint = s.codePointAt(index); + if (!Character.isLetter(codePoint)) continue; + final int cutoff = index + Character.charCount(codePoint); + final String firstLetter = s.substring(index, cutoff); + final String lowercaseLetter = firstLetter.toLowerCase(locale); + if (firstLetter.equals(lowercaseLetter)) return s; + return s.substring(0, index) + lowercaseLetter + s.substring(cutoff); + } + return s; + } + @NonNull public static String capitalizeFirstAndDowncaseRest(@NonNull final String s, @NonNull final Locale locale) { @@ -185,6 +200,16 @@ public static String getStringFromNullTerminatedCodePointArray( return new String(codePoints, 0 /* offset */, stringLength); } + public static boolean hasAtLeastTwoLetters(@NonNull final String text) { + int letterCount = 0; + for (int index = 0; index < text.length(); index = text.offsetByCodePoints(index, 1)) { + if (Character.isLetter(text.codePointAt(index)) && ++letterCount == 2) { + return true; + } + } + return false; + } + // This method assumes the text is not null. For the empty string, it returns CAPITALIZE_NONE. public static int getCapitalizationType(@NonNull final String text) { // If the first char is not uppercase, then the word is either all lower case or diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 78d08db5f..bf6527f22 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -4055,12 +4055,12 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, + " prevTyped='" + prevTypedWord + "'" + " chosen='" + batchInputText + "'"); } - // Auto-capitalize the first letter of a fresh-word gesture when the keyboard is in - // auto-shifted / manual-shifted / shift-locked state. The gesture-recognizer always - // returns lowercase, so without this fix swiping "Hello" at sentence-start types - // "hello". We deliberately skip this when extending an existing composing word, since - // those continuation gestures should append in the casing the user already chose for - // the start of the word. + // Apply presentation casing at the batch-commit boundary. Recognizers usually emit + // lowercase, but native/dictionary-backed candidates may preserve title casing. The + // gesture-start shift snapshot is authoritative: shifted modes add requested casing, + // while OFF removes only unrequested multi-letter title casing. We deliberately skip + // this when extending an existing composing word, since continuation gestures should + // append in the casing the user already chose for the start of the word. if (!extendExistingCompose && !batchInputText.isEmpty()) { // Use the shift mode captured at gesture-start, not the live mode — the // keyboard auto-clears the shifted indicator during the gesture, so a live @@ -4072,6 +4072,12 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, } else if (shiftMode == WordComposer.CAPS_MODE_AUTO_SHIFT_LOCKED || shiftMode == WordComposer.CAPS_MODE_MANUAL_SHIFT_LOCKED) { batchInputText = batchInputText.toUpperCase(settingsValues.mLocale); + } else if (shiftMode == WordComposer.CAPS_MODE_OFF + && StringUtils.hasAtLeastTwoLetters(batchInputText) + && StringUtils.getCapitalizationType(batchInputText) + == StringUtils.CAPITALIZE_FIRST) { + batchInputText = StringUtils.lowercaseFirstLetterCodePoint( + batchInputText, settingsValues.mLocale); } } // Clear so a stale value from a previous gesture can't leak into a non-gesture diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index df2119ea0..54beff936 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -70,6 +70,7 @@ class InputLogicTest { private val composingReader = RichInputConnection::class.java.getDeclaredField("mComposingText").apply { isAccessible = true } private val connectionComposingText get() = (composingReader.get(connection) as CharSequence).toString() private val combiningGraceExpired = InputLogic::class.java.getDeclaredMethod("onCombiningGraceExpired").apply { isAccessible = true } + private val gestureShiftMode = InputLogic::class.java.getDeclaredField("mShiftModeAtGestureStart").apply { isAccessible = true } @BeforeTest fun setUp() { @@ -456,6 +457,29 @@ class InputLogicTest { assertEquals("deal", textBeforeCursor) } + @Test fun unshiftedGestureDoesNotPromoteTitleCaseCandidates() { + val actual = listOf("To", "No", "Meet", "I", "RJ", "iPhone").map { candidate -> + reset() + setText("x ") // mid-sentence: keyboard caps mode is off + gestureInput(candidate) + textBeforeCursor.removePrefix("x ") + } + assertEquals(listOf("to", "no", "meet", "I", "RJ", "iPhone"), actual) + } + + @Test fun gesturePresentationCasingStillFollowsCapturedShiftMode() { + fun committed(candidate: String, shiftMode: Int): String { + reset() + setText("x ") + gestureShiftMode.setInt(inputLogic, shiftMode) + glideTypingInput(candidate) + return textBeforeCursor.removePrefix("x ") + } + + assertEquals("Meet", committed("meet", WordComposer.CAPS_MODE_AUTO_SHIFTED)) + assertEquals("MEET", committed("Meet", WordComposer.CAPS_MODE_MANUAL_SHIFT_LOCKED)) + } + // Live-converge OFF (default): a tap after a swipe appends literally to the recognized // fragment. This documents the baseline the opt-in changes (on-device, the tap would instead // re-recognize the whole stroke). "RJ" stands in for a mis-resolved short swipe fragment. diff --git a/app/src/test/java/helium314/keyboard/latin/StringUtilsTest.kt b/app/src/test/java/helium314/keyboard/latin/StringUtilsTest.kt index 3fbba6471..0e72e15fe 100644 --- a/app/src/test/java/helium314/keyboard/latin/StringUtilsTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/StringUtilsTest.kt @@ -18,6 +18,7 @@ import helium314.keyboard.latin.utils.TextRange import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.util.Locale import kotlin.test.Test import kotlin.test.assertEquals @@ -27,6 +28,25 @@ import kotlin.test.assertEquals ShadowInputMethodManager2::class, ]) class StringUtilsTest { + @Test fun lowercaseFirstLetterCodePointHandlesPrefixesAndSupplementaryPlaneLetter() { + val uppercaseDeseret = String(Character.toChars(0x10400)) + "word" + val lowercaseDeseret = String(Character.toChars(0x10428)) + "word" + + assertEquals( + lowercaseDeseret, + StringUtils.lowercaseFirstLetterCodePoint(uppercaseDeseret, Locale.ENGLISH), + ) + assertEquals(true, StringUtils.hasAtLeastTwoLetters(uppercaseDeseret)) + assertEquals(false, StringUtils.hasAtLeastTwoLetters("I'")) + assertEquals("'to", StringUtils.lowercaseFirstLetterCodePoint("'To", Locale.ENGLISH)) + assertEquals("'iPhone", StringUtils.lowercaseFirstLetterCodePoint("'iPhone", Locale.ENGLISH)) + assertEquals("'123", StringUtils.lowercaseFirstLetterCodePoint("'123", Locale.ENGLISH)) + assertEquals( + "istanbul", + StringUtils.lowercaseFirstLetterCodePoint("\u0130stanbul", Locale.forLanguageTag("tr")), + ) + } + @Test fun `not inside double quotes without quotes`() { assert(!StringUtils.isInsideDoubleQuoteOrAfterDigit("hello yes")) } diff --git a/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt b/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt index 87bcc66ff..c0e4aeec1 100644 --- a/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt @@ -153,6 +153,21 @@ class SuggestTest { // todo: consider special score for case-only difference? } + @Test fun `lowercase words are not autocorrected to case-only capitalized candidates`() { + val locale = Locale.ENGLISH + val actual = listOf("to" to "To", "no" to "No", "meet" to "Meet").map { (typed, candidate) -> + shouldBeAutoCorrected( + typed, + listOf(suggestion(candidate, Int.MAX_VALUE, locale), suggestion(typed, 1500000, locale)), + suggestion(candidate, 200, locale), + suggestion(typed, 200, locale), + locale, + thresholdModest, + ).last() + } + assertEquals(listOf(false, false, false), actual) + } + @Test fun `no English 'in' instead of French 'un' when typing in French`() { val result = shouldBeAutoCorrected( "un", diff --git a/docs/releasenote/release_notes_v3.11.0.md b/docs/releasenote/release_notes_v3.11.0.md index 9bcee01eb..ade572680 100644 --- a/docs/releasenote/release_notes_v3.11.0.md +++ b/docs/releasenote/release_notes_v3.11.0.md @@ -5,6 +5,7 @@ - Configure a target input method and switch directly to it using toolbar keycode `-10076`, including on Android 6–8. - Create and persist up to five custom layout slots across symbol mode, rotation, and keyboard reloads. - Configure auto-correct trigger characters and optionally suppress multi-word suggestions. +- Keep ordinary lowercase words lowercase when typing or swiping without Shift. - Use the optimized built-in Java gesture engine with lower memory usage, improved ranking, and corrected suggestion casing. - Browse an updated dictionary download catalog with stale unavailable entries removed and newly published dictionaries added. - Benefit from more reliable Text Expander placeholder navigation and dictionary/blacklist handling. diff --git a/fastlane/metadata/android/en-US/changelogs/4100.txt b/fastlane/metadata/android/en-US/changelogs/4100.txt index 90d89a7fc..48184a247 100644 --- a/fastlane/metadata/android/en-US/changelogs/4100.txt +++ b/fastlane/metadata/android/en-US/changelogs/4100.txt @@ -1,5 +1,6 @@ - Switch directly to a configured keyboard and subtype, including on Android 6–8. - Save and restore up to five custom layout slots. - Configure auto-correct triggers and hide multi-word suggestions. +- Keep ordinary lowercase words lowercase when typing or swiping without Shift. - Faster, lower-memory built-in gesture typing with corrected suggestion casing. - Updated the dictionary download catalog and improved Text Expander and blacklist reliability.