diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml deleted file mode 100644 index 80a9525b9..000000000 --- a/.github/workflows/build-release-apk.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Build Release APKs - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Decode Keystore - env: - RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }} - if: ${{ env.RELEASE_KEYSTORE != '' }} - run: | - echo "${{ secrets.RELEASE_KEYSTORE }}" | base64 -d > leantype-release.jks - echo "keyAlias=${{ secrets.RELEASE_KEY_ALIAS }}" > keystore.properties - echo "keyPassword=${{ secrets.RELEASE_KEY_PASSWORD }}" >> keystore.properties - echo "storeFile=leantype-release.jks" >> keystore.properties - echo "storePassword=${{ secrets.RELEASE_STORE_PASSWORD }}" >> keystore.properties - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build Release APKs - run: ./gradlew assembleStandardRelease assembleStandardfullRelease assembleOfflineRelease assembleOfflineliteRelease - - - name: Generate Release Notes - # ponytail: generate release notes from changelog during build - run: python3 docs/scripts/generate_release_notes.py - - - name: Upload APKs and Release Notes - uses: actions/upload-artifact@v4 - with: - name: LeanType-Release-APKs - path: | - app/build/outputs/apk/**/*.apk - docs/releasenote/release_notes_v*.md - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/v') - with: - prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }} - body_path: docs/releasenote/release_notes_temp.md - files: | - app/build/outputs/apk/standard/release/*.apk - app/build/outputs/apk/standardfull/release/*.apk - app/build/outputs/apk/offline/release/*.apk - app/build/outputs/apk/offlinelite/release/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d2f384aa..fd3c6f7c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. # @@ -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: | @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a0d055f10..4bd59613d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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/.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]`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aedbca2c..01f39ff30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71f3f648d..bac943480 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 @@ -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") @@ -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 @@ -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") } } @@ -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." + ) + } + } +} diff --git a/app/src/main/assets/dictionaries_in_dict_repo.csv b/app/src/main/assets/dictionaries_in_dict_repo.csv index 52f83fd5c..f00b45d46 100644 --- a/app/src/main/assets/dictionaries_in_dict_repo.csv +++ b/app/src/main/assets/dictionaries_in_dict_repo.csv @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 @@ -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, @@ -238,6 +244,7 @@ main,ta, emoji,te,cldr main,te, emoji,th,cldr +main,th, emoji,ti,cldr main,tok, emoji,to,cldr @@ -245,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/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt b/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt index 3b0014ada..960357565 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt @@ -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) diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt index 7cd332834..0bcf9e2f8 100644 --- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt +++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt @@ -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 @@ -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. 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 d47d4a4a2..c168007c7 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -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 @@ -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 diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index 07d41ae1a..7d3fddf24 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -73,6 +73,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() { @@ -602,6 +603,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_v0.1.0.md b/docs/releasenote/release_notes_v0.1.0.md new file mode 100644 index 000000000..289052320 --- /dev/null +++ b/docs/releasenote/release_notes_v0.1.0.md @@ -0,0 +1,25 @@ +# LeanTypeDual 0.1.0 + +## Highlights + +- 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. +- Control first- and next-word suggestions, background services, and immediate suggestion spacing. +- 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. + +## 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's visible version series restarts at `0.1.0`. Android version code `4100` remains above the previous `3.10.0`/`4000` release, so existing LeanTypeDual installations upgrade normally. The separate `com.asafmah.leantypedual` application ID continues to prevent collisions with upstream LeanType. + +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..9ec480bcf --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4100.txt @@ -0,0 +1,7 @@ +- 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, first-word, next-word, and multi-word suggestions. +- Manage background services and optional immediate suggestion spacing. +- 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. 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()