Skip to content
Closed
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
65 changes: 0 additions & 65 deletions .github/workflows/build-release-apk.yml

This file was deleted.

22 changes: 18 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Builds the signed release APKs (all three flavors) and drafts a GitHub Release.
# Builds the signed release APKs (all four flavors) and drafts a GitHub Release.
#
# Triggers on pushing a version tag (e.g. `git tag v3.8.5 && git push origin v3.8.5`),
# Triggers on pushing a version tag (e.g. `git tag v0.1.0 && git push origin v0.1.0`),
# or manually via "Run workflow" (workflow_dispatch) for a signing/build dry run that
# uploads the APKs as an artifact WITHOUT creating a Release.
#
Expand Down Expand Up @@ -60,7 +60,21 @@ jobs:
EOF

- name: Build signed release APKs (all flavors)
run: ./gradlew :app:assembleStandardRelease :app:assembleOfflineRelease :app:assembleOfflineliteRelease
run: ./gradlew :app:assembleStandardRelease :app:assembleStandardfullRelease :app:assembleOfflineRelease :app:assembleOfflineliteRelease

- name: Verify release APK signatures
run: |
APKSIGNER="$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner -type f | sort -V | tail -1)"
test -x "$APKSIGNER"
count=0
for apk in app/build/outputs/apk/*/release/*.apk; do
"$APKSIGNER" verify --verbose --print-certs "$apk"
count=$((count + 1))
done
test "$count" -eq 4

- name: Generate release notes
run: python3 docs/scripts/generate_release_notes.py

- name: Collect APKs
run: |
Expand All @@ -80,5 +94,5 @@ jobs:
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
body_path: docs/releasenote/release_notes_temp.md
files: release-apks/*.apk
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,5 @@ This convention is loaded every session, so any agent (and future-you) is expect
Keep `CHANGELOG.md` current — it is LeanTypeDual's own history, not a per-line provenance log.
- **Every user-facing or notable change** gets a line under `## [Unreleased]` (or the in-progress version), grouped `Added` / `Changed` / `Fixed` / `Reliability & testing`, with the `(#N)` issue/PR ref. Internal-only refactors go under `Changed`/`Reliability`; do not enumerate them in the user-facing fastlane note.
- **Provenance is coarse, not per-entry.** Do NOT tag each line ours/LeanType/HeliBoard. When upstream code is merged in, add a single `Upstream` marker line under that release (e.g. `Upstream — merged HeliBoard 3.9`). Everything not under an `Upstream` marker is original to this fork by default. The fork-only feature set lives in the README, not the changelog.
- **Versioning:** SemVer `versionName` in `app/build.gradle.kts`; `versionCode` follows `major*1000 + minor*100 + patch*10` (e.g. `3.9.0` → `3900`). On release, also add `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (terse, user-facing bullets only). Release chores: `tools/release.py`.
- **Versioning:** LeanTypeDual restarted its visible SemVer at `0.1.0`, independently of upstream. To preserve Android upgrades from the previous `3.10.0`/`4000` fork release, `versionCode` uses the offset formula `4000 + major*1000 + minor*100 + patch*10` (`0.1.0` → `4100`). On release, also add `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (terse, user-facing bullets only). Release chores: `tools/release.py`.
- On cutting a release, rename `[Unreleased]` to the version + date and start a fresh `[Unreleased]`.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,28 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [0.1.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 v4.0.2** (pinned at `0477ef83`, including v4.0.0/v4.0.1) — adds JNI and lifecycle hardening, first-word and next-word controls, background-service controls, immediate autospace, translation-history improvements, and pointer/input-connection stability fixes. LeanTypeDual retains its Java fallback gesture engine, distinct `applicationId`, privacy tiers, two-thumb behavior, and fork-owned release metadata. (#123)
Expand Down
29 changes: 24 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
val releaseStoreFile = keystoreProperties.getProperty("storeFile")?.let(rootProject::file)
val releaseSigningConfigured = releaseStoreFile?.isFile == true
&& listOf("storePassword", "keyAlias", "keyPassword").all { key ->
keystoreProperties.getProperty(key)?.let { it.isNotBlank() && it != "YOUR_PASSWORD" } == true
}

android {
compileSdk = 36
Expand All @@ -22,8 +27,8 @@ android {
applicationId = "com.asafmah.leantypedual"
minSdk = 21
targetSdk = 35
versionCode = 4000
versionName = "3.10.0"
versionCode = 4100
versionName = "0.1.0"

proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")

Expand Down Expand Up @@ -54,11 +59,11 @@ android {
}

signingConfigs {
if (keystorePropertiesFile.exists()) {
if (releaseSigningConfigured) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storeFile = releaseStoreFile
storePassword = keystoreProperties["storePassword"] as String
enableV1Signing = true
enableV2Signing = true
Expand All @@ -73,7 +78,7 @@ android {
isShrinkResources = true // Enable resource shrinking to reduce APK size and memory usage
isDebuggable = false
isJniDebuggable = false
if (keystorePropertiesFile.exists()) {
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
}
Expand Down Expand Up @@ -288,3 +293,17 @@ tasks.configureEach {
enabled = false
}
}

if (!releaseSigningConfigured) {
tasks.matching {
it.name.endsWith("Release") && (it.name.startsWith("assemble")
|| it.name.startsWith("bundle") || it.name.startsWith("package"))
}.configureEach {
outputs.upToDateWhen { false }
doFirst {
throw GradleException(
"Release signing is not configured. Provide a real keystore.properties and keystore; unsigned release artifacts are forbidden."
)
}
}
}
10 changes: 9 additions & 1 deletion app/src/main/assets/dictionaries_in_dict_repo.csv
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ emoji,as,cldr
main,as,
emoji,ast,cldr
emoji,az,cldr
main,az,
emoji,bn,cldr
main,bn,
main,bn2,
emoji,eu,cldr
main,eu,
emoji,be,cldr
Expand Down Expand Up @@ -60,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 @@ -117,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 @@ -153,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 @@ -162,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 @@ -216,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 @@ -238,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
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,11 @@ class RichInputMethodManager private constructor() {
}

private fun initInternal(ctx: Context) {
if (isInitializedInternal) {
val newInputMethodManager = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (isInitializedInternal && imm === newInputMethodManager) {
return
}
imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm = newInputMethodManager
context = ctx
inputMethodInfoCache = InputMethodInfoCache(imm, ctx.packageName)

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 @@ -262,6 +262,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 @@ -346,6 +347,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 @@ -4165,12 +4165,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 @@ -4182,6 +4182,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
Loading
Loading