Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b980dc0
chore: update README badges [skip ci]
github-actions[bot] Jul 12, 2026
4ec2099
feat: fix direct switch on language key & add app language option
LeanBitLab Jul 11, 2026
b07812b
feat: disable gesture typing by default
LeanBitLab Jul 11, 2026
7322864
perf(spellcheck): add cache lookup, check personal dict, fix regex
LeanBitLab Jul 11, 2026
0ddb80d
fix(layout): support multi-row number row layouts
LeanBitLab Jul 11, 2026
d530e2d
feat: option to use system emoji font
LeanBitLab Jul 12, 2026
dcfcff3
style: shorten system emoji preference summary
LeanBitLab Jul 12, 2026
cb9f7f0
fix: commit system emoji preference synchronously before app exit
LeanBitLab Jul 12, 2026
b252987
feat: add diagnostic logging for system emoji toggling
LeanBitLab Jul 12, 2026
319ff45
chore: remove diagnostic emoji logging
LeanBitLab Jul 12, 2026
cecd5a0
chore: bump version to v3.9.6 & update changelogs
LeanBitLab Jul 12, 2026
2518966
chore: update README badges [skip ci]
github-actions[bot] Jul 13, 2026
19f5df2
chore: update README badges [skip ci]
github-actions[bot] Jul 14, 2026
3f7c6c5
fix(build): programmatically disable r8 fullmode to guarantee reprodu…
LeanBitLab Jul 13, 2026
a8e0b15
fix: resolve turkish case folding, translation UI consistency, and an…
LeanBitLab Jul 14, 2026
f6ace56
fix: display close button in translate language selector
LeanBitLab Jul 14, 2026
0ee86c6
fix: optimize fallback gesture indexer to run in background
LeanBitLab Jul 14, 2026
8d49c98
feat: add experimental Kotlin gesture engine as third method option
LeanBitLab Jul 14, 2026
ad0c39b
perf: optimize Kotlin gesture engine performance and script support
LeanBitLab Jul 14, 2026
4d34272
feat: add developer-mode-restricted settings and keycode hooks for ex…
LeanBitLab Jul 14, 2026
af92f81
chore: update README badges [skip ci]
github-actions[bot] Jul 15, 2026
0d8ffa1
feat: prevent dict deletion and support upgrades
LeanBitLab Jul 15, 2026
f77301b
test: add AppUpgradeTest for dictionary cleanup
LeanBitLab Jul 15, 2026
57d9152
feat: toolbar swipe close & gesture oom fix
LeanBitLab Jul 15, 2026
9dea8dc
feat: sort translation target languages by last used first
LeanBitLab Jul 15, 2026
c15a508
fix: show multilingual typing option when at least one secondary lang…
LeanBitLab Jul 15, 2026
16a9c13
fix: update dictionary status immediately in missing dialog
LeanBitLab Jul 15, 2026
6eef3eb
docs: update release notes and changelog for v3.9.7
LeanBitLab Jul 15, 2026
fa0e0ac
fix: distinguish standard and experimental layouts and check updates …
LeanBitLab Jul 15, 2026
a491182
chore: sync fastlane screenshots with readme
LeanBitLab Jul 15, 2026
e1efc1f
chore: update README badges [skip ci]
github-actions[bot] Jul 16, 2026
700d890
chore: bump version to v3.9.8 and fix crashes/clean up kotlin engine
LeanBitLab Jul 16, 2026
c6dc99f
fix: resolve crashes, ANRs, and dict deletion
LeanBitLab Jul 16, 2026
3f1c039
release: v3.9.9
LeanBitLab Jul 16, 2026
61e62b6
fix: clean up old dictionaries asynchronously
LeanBitLab Jul 16, 2026
adb7c37
docs: update release notes and changelog for v3.9.9
LeanBitLab Jul 16, 2026
8cc6ebe
merge: sync LeanBitLab v3.9.9
AsafMah Jul 17, 2026
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
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,14 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot"
- Docs: `docs/FEATURES.md`, `docs/TWO_THUMB_TYPING_INTERNALS.md`, `docs/IMPROVEMENT_PLAN.md`, `layouts.md`, `CONTRIBUTING.md`

## Runtime/Tooling Preferences
- **Android:** `compileSdk`/`targetSdk` 35, `minSdk` 21; Java/Kotlin JVM target 17. Build with JDK 17 or 21.
- **Android:** `compileSdk` 36, `targetSdk` 35, `minSdk` 21; Java/Kotlin JVM target 17. Build with JDK 17 or 21.
- **Toolchain:** Gradle 8.13 (wrapper), Kotlin 2.2.21, Compose BOM 2025.11.01 (`material3`, `navigation-compose`). Package management is **Gradle only** (no npm/bun/yarn).
- **Native:** ABIs `armeabi-v7a`, `arm64-v8a`; built via `ndkBuild` (`app/src/main/jni/Android.mk`).
- **Flavors** (dimension `privacy`, appId base `com.leanbitlab.leantype`):
- **Flavors** (dimension `privacy`, appId base `com.asafmah.leantypedual`):
- `standard` — cloud AI (Gemini, `generativeai`), has `INTERNET`.
- `offline` — on-device ONNX (`onnxruntime-android`), **no** `INTERNET`; appId `+.offline`.
- `offlinelite` — no AI, smallest; appId `+.offlinelite`.
- `standardfull` — cloud AI plus handwriting, has `INTERNET`.
- `offline` — on-device llama.cpp / GGUF, **no** `INTERNET`; appId `+.offline`, minSdk 26.
- `offlinelite` — no AI, smallest; **no** `INTERNET`; appId `+.offlinelite`.
- **Build types:** `debug` (no minify, `+.debug`), `release` (minify + shrink + signed via `keystore.properties`), `runTests` (CI variant that skips known-failing tests), `debugNoMinify` (fast IDE builds).
- **CI:** `.github/workflows/build-test-auto.yml` runs `compileOfflineRunTestsKotlin` on PRs touching `app/src/main/java**`; `build-debug-apk.yml` runs `assembleDebug` on manual dispatch. Release chores live in `tools/release.py`.

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **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)

### 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.9** (pinned at `adb7c3755`, including v3.9.6–v3.9.8) — adds system-emoji and app-language settings, multi-row number rows, toolbar swipe-to-dismiss, translation and spellcheck improvements, asynchronous fallback-gesture indexing, dictionary upgrade preservation, and native dictionary crash/ANR fixes. Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. (#121)

## [3.10.0] - 2026-06-20

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ dependencies {
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.autofill:autofill:1.3.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.emoji2:emoji2:1.4.0")

// kotlin
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
Expand Down
11 changes: 1 addition & 10 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

<!-- Disable WorkManager auto-initializer; App implements Configuration.Provider instead.
Required for ML Kit Digital Ink plugin loaded via DexClassLoader. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:name="androidx.emoji2.text.EmojiCompatInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
Expand Down
37 changes: 32 additions & 5 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,13 +26,40 @@ object ImeCompat {
return RichInputMethodManager.getInstance().inputMethodManager.shouldOfferSwitchingToNextInputMethod(token)
}

fun InputMethodService.switchInputMethodAndSubtype(imi: InputMethodInfo, subtype: InputMethodSubtype) {
fun InputMethodService.switchInputMethodCompat(imiId: String) {
val window = window.window
val token = window?.attributes?.token
if (token != null) {
try {
RichInputMethodManager.getInstance().inputMethodManager.setInputMethod(token, imiId)
return
} catch (e: Throwable) {
// fallback
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
switchInputMethod(imiId)
}
}

fun InputMethodService.switchInputMethodAndSubtypeCompat(imi: InputMethodInfo, subtype: InputMethodSubtype) {
val window = window.window
val token = window?.attributes?.token
if (token != null) {
try {
RichInputMethodManager.getInstance().inputMethodManager.setInputMethodAndSubtype(token, imi.id, subtype)
return
} catch (e: Throwable) {
// fallback
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
switchInputMethod(imi.id, subtype)
} else {
val window = window.window ?: return
val token = window.attributes.token
RichInputMethodManager.getInstance().inputMethodManager.setInputMethodAndSubtype(token, imi.id, subtype)
val fallbackToken = token ?: return
try {
RichInputMethodManager.getInstance().inputMethodManager.setInputMethod(fallbackToken, imi.id)
} catch (e: Throwable) {}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,18 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co
params.mBaseWidth = params.mOccupiedWidth - params.mLeftPadding - params.mRightPadding
}

val numberRow = getNumberRow()
val numberRows = getNumberRows()
val numberRow = numberRows.first()
addNumberRowOrPopupKeys(baseKeys, numberRow)
if (params.mId.isAlphabetKeyboard)
addSymbolPopupKeys(baseKeys)
if (params.mId.isAlphaOrSymbolKeyboard && (params.mId.mNumberRowEnabled
|| (!params.mId.isAlphabetKeyboard && params.mId.mNumberRowInSymbols && !params.mId.mCompactNumberRowInSymbols))) {
val newLabelFlags = defaultLabelFlags or
if (Settings.getValues().mShowNumberRowHints) 0 else Key.LABEL_FLAGS_DISABLE_HINT_LABEL
baseKeys.add(0, numberRow.mapTo(mutableListOf()) { it.copy(newLabelFlags = newLabelFlags) })
numberRows.forEachIndexed { rowIndex, row ->
baseKeys.add(rowIndex, row.mapTo(mutableListOf()) { it.copy(newLabelFlags = newLabelFlags) })
}
}
if (!params.mAllowRedundantPopupKeys)
params.baseKeys = baseKeys.flatMap { row -> row.map { it.toKeyParams(params) } }
Expand Down Expand Up @@ -317,38 +320,49 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co
}
}

private fun getNumberRow(): MutableList<KeyData> {
val row = LayoutParser.parseLayout(LayoutType.NUMBER_ROW, params, context).first()
private fun getNumberRows(): MutableList<MutableList<KeyData>> {
val rows = LayoutParser.parseLayout(LayoutType.NUMBER_ROW, params, context)
if (rows.isEmpty()) {
rows.add(mutableListOf())
}
val localizedNumbers = params.mLocaleKeyboardInfos.localizedNumberKeys
if (localizedNumbers?.size == 10) {
if (Settings.getValues().mLocalizedNumberRow) {
// replace 0-9 with localized numbers, and move latin number into popup
for (i in row.indices) {
val key = row[i]
val number = key.label.toIntOrNull() ?: continue
when (number) {
0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
for (row in rows) {
if (Settings.getValues().mLocalizedNumberRow) {
// replace 0-9 with localized numbers, and move latin number into popup
for (i in row.indices) {
val key = row[i]
val number = key.label.toIntOrNull() ?: continue
when (number) {
0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
}
}
}
} else {
// add localized numbers to popups on 0-9
for (i in row.indices) {
val key = row[i]
val number = key.label.toIntOrNull() ?: continue
when (number) {
0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup))
in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup))
} else {
// add localized numbers to popups on 0-9
for (i in row.indices) {
val key = row[i]
val number = key.label.toIntOrNull() ?: continue
when (number) {
0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup))
in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup))
}
}
}
}
}
if (params.mId.mElementId == KeyboardId.ELEMENT_SYMBOLS_SHIFTED) {
for (i in row.indices) {
row[i] = shiftKeyData(row[i])
for (row in rows) {
for (i in row.indices) {
row[i] = shiftKeyData(row[i])
}
}
}
return row
return rows
}

private fun getNumberRow(): MutableList<KeyData> {
return getNumberRows().first()
}

private fun shiftKeyData(key: KeyData): KeyData {
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package helium314.keyboard.latin

import android.app.Application
import androidx.emoji2.text.EmojiCompat
import androidx.emoji2.text.DefaultEmojiCompatConfig
import androidx.work.Configuration
import helium314.keyboard.keyboard.emoji.SupportedEmojis
import helium314.keyboard.latin.define.DebugFlags
Expand All @@ -23,6 +25,13 @@ class App : Application(), Configuration.Provider {
super.onCreate()
DebugFlags.init(this)
Settings.init(this)
val useSystemEmoji = Settings.getInstance().useSystemEmoji()
if (!useSystemEmoji) {
val config = DefaultEmojiCompatConfig.create(this)
if (config != null) {
EmojiCompat.init(config)
}
}
SubtypeSettings.init(this)
RichInputMethodManager.init(this)

Expand Down
50 changes: 47 additions & 3 deletions app/src/main/java/helium314/keyboard/latin/AppUpgrade.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode.check
import helium314.keyboard.latin.common.ColorType
import helium314.keyboard.latin.common.Constants.Separators
import helium314.keyboard.latin.common.Constants.Subtype.ExtraValue
import helium314.keyboard.latin.common.LocaleUtils
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.common.encodeBase36
import helium314.keyboard.latin.database.ClipboardDao
Expand Down Expand Up @@ -57,10 +58,53 @@ object AppUpgrade {
if (oldVersion == BuildConfig.VERSION_CODE)
return
// clear extracted dictionaries, in case updated version contains newer ones
DictionaryInfoUtils.getCacheDirectories(context).forEach {
for (file in it.listFiles()!!) {
if (!file.name.endsWith(USER_DICTIONARY_SUFFIX))
val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context)
DictionaryInfoUtils.getCacheDirectories(context).forEach { dir ->
val locale = DictionaryInfoUtils.getWordListIdFromFileName(dir.name).constructLocale()
for (file in dir.listFiles().orEmpty()) {
if (file.name.endsWith(USER_DICTIONARY_SUFFIX)) continue

val type = file.name.substringBefore("_").substringBefore(".dict")

// Check if this dictionary type has a corresponding bundled asset for this locale
val hasAsset = if (assetsList != null) {
val matchingAssets = assetsList.filter { it.startsWith("${type}_") }
LocaleUtils.getBestMatch(locale, matchingAssets) { asset ->
DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(asset)
} != null
} else {
false
}

// Check if there is a download preference for this dictionary
val hasDownloadPref = prefs.contains("pref_dict_download_link_${type}_${locale}")
|| prefs.contains("pref_dict_download_link_${type}_${locale.toLanguageTag()}")

var isExtractedAsset = prefs.getBoolean("pref_extracted_asset_${type}_${locale.toLanguageTag()}", false)
|| prefs.getBoolean("pref_extracted_asset_${type}_${locale}", false)

// For backward compatibility, check if the file size matches the current asset
if (!isExtractedAsset && hasAsset && assetsList != null) {
val matchingAssets = assetsList.filter { it.startsWith("${type}_") }
val bestAsset = LocaleUtils.getBestMatch(locale, matchingAssets) { asset ->
DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(asset)
}
if (bestAsset != null) {
runCatching {
context.assets.open("${DictionaryInfoUtils.ASSETS_DICTIONARY_FOLDER}/$bestAsset").use { input ->
if (file.length() == input.available().toLong()) {
isExtractedAsset = true
prefs.edit().putBoolean("pref_extracted_asset_${type}_${locale.toLanguageTag()}", true).apply()
}
}
}
}
}

// Only delete if it is an asset-backed dictionary, was marked as extracted, and wasn't downloaded by the user
if (hasAsset && isExtractedAsset && !hasDownloadPref) {
file.delete()
}
}
}
if (oldVersion <= 1000) { // upgrade old custom layouts name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,17 @@ class ClipboardHistoryManager(
// allocating a fresh Handler on every postDelayed().
private val mainHandler = Handler(Looper.getMainLooper())
private var clipboardSuggestionView: View? = null
private var clipboardDao: ClipboardDao? = null
private var _clipboardDao: ClipboardDao? = null
private var clipboardDao: ClipboardDao?
get() {
if (_clipboardDao == null || _clipboardDao?.isClosed == true) {
_clipboardDao = ClipboardDao.getInstance(latinIME)
}
return _clipboardDao
}
set(value) {
_clipboardDao = value
}
private var dontShowCurrentSuggestion: Boolean = false
private var mediaStoreObserver: ContentObserver? = null
// ponytail: track last clip state to avoid resetting dismiss state on duplicate events
Expand Down
Loading
Loading