Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,31 @@ 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. (#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. (#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
- **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
- 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. (#118)

## [3.10.0] - 2026-06-20

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
12 changes: 9 additions & 3 deletions app/src/main/assets/dictionaries_in_dict_repo.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -240,13 +244,15 @@ main,ta,
emoji,te,cldr
main,te,
emoji,th,cldr
main,th,
emoji,ti,cldr
main,tok,
emoji,to,cldr
emoji,tn,cldr
main,tcy,
emoji,tr,cldr
main,tr,
main,tr2,
main,tr,exp
emoji,tk,cldr
emoji,uk,
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/java/helium314/keyboard/compat/ImeCompat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/helium314/keyboard/latin/LatinIME.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/Suggest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/common/StringUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/java/helium314/keyboard/Shadows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() {
@Implementation
fun getShortcutInputMethodsAndSubtypes() = emptyMap<InputMethodInfo, List<InputMethodSubtype>>()

@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,
Expand All @@ -51,10 +69,16 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() {

var inputMethods: List<InputMethodInfo> = listOf(defaultInputMethod())
val enabledSubtypes = mutableMapOf<String, List<InputMethodSubtype>>()
var switchedImeId: String? = null
var switchedSubtype: InputMethodSubtype? = null
var switchedToNextInputMethod = false

fun reset() {
inputMethods = listOf(defaultInputMethod())
enabledSubtypes.clear()
switchedImeId = null
switchedSubtype = null
switchedToNextInputMethod = false
}
}
}
Expand Down
99 changes: 99 additions & 0 deletions app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt
Original file line number Diff line number Diff line change
@@ -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<Context>())
}

@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()
}
}
Loading
Loading