diff --git a/.github/workflows/build-test-auto.yml b/.github/workflows/build-test-auto.yml index 2cbbc787f..d0cbd8ae9 100644 --- a/.github/workflows/build-test-auto.yml +++ b/.github/workflows/build-test-auto.yml @@ -17,6 +17,8 @@ on: - '.github/workflows/release.yml' - 'tools/check_fork_invariants.py' - 'tools/check_apk_invariants.py' + - 'tools/check_test_results.py' + - 'tools/test_baselines/**' - 'tools/tests/**' - 'fastlane/metadata/android/en-US/changelogs/**' push: @@ -30,6 +32,8 @@ on: - '.github/workflows/release.yml' - 'tools/check_fork_invariants.py' - 'tools/check_apk_invariants.py' + - 'tools/check_test_results.py' + - 'tools/test_baselines/**' - 'tools/tests/**' - 'fastlane/metadata/android/en-US/changelogs/**' workflow_dispatch: diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index fa8a00b01..5aa98c307 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -6,10 +6,10 @@ name: Native tests on: push: branches: [dev] - paths: ['app/src/main/jni/**'] + paths: ['app/src/main/jni/**', '.github/workflows/native-tests.yml'] pull_request: branches: [dev, main] - paths: ['app/src/main/jni/**'] + paths: ['app/src/main/jni/**', '.github/workflows/native-tests.yml'] jobs: native-host-tests: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47a2b8efc..7d5d2af12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Builds the signed release APKs (all four flavors) and drafts a GitHub Release. +# Builds the signed release APKs (all three flavors) and drafts a GitHub Release. # # 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 @@ -60,7 +60,7 @@ jobs: EOF - name: Build signed release APKs (all flavors) - run: ./gradlew :app:assembleStandardRelease :app:assembleStandardfullRelease :app:assembleOfflineRelease :app:assembleOfflineliteRelease + run: ./gradlew :app:assembleStandardRelease :app:assembleStandardfullRelease :app:assembleOfflineRelease - name: Verify packaged LeanTypeDual invariants run: | @@ -77,14 +77,14 @@ jobs: for apk in app/build/outputs/apk/*/release/*.apk; do "$APKSIGNER" verify --verbose --print-certs "$apk" case "$apk" in - *-standard-release.apk|*-standardfull-release.apk|*-offlinelite-release.apk) + *-standard-release.apk|*-standardfull-release.apk|*-offline-release.apk) "$APKSIGNER" verify --verbose --min-sdk-version 21 --max-sdk-version 23 "$apk" legacy_count=$((legacy_count + 1)) ;; esac count=$((count + 1)) done - test "$count" -eq 4 + test "$count" -eq 3 test "$legacy_count" -eq 3 - name: Generate release notes diff --git a/AGENTS.md b/AGENTS.md index 38060704f..9ff13bc8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## Project Overview -LeanType is an Android keyboard (an `InputMethodService` app), forked from HeliBoard/OpenBoard/AOSP LatinIME. On top of the upstream keyboard it adds AI proofreading & translation (cloud and on-device ONNX), Nintype-style two-thumb typing, custom AI toolbar keys, and a floating keyboard. The legacy input engine is **Java**; newer logic, settings, and AI code are **Kotlin**. Settings UI is **Jetpack Compose**. A native **C++** engine (under `app/src/main/jni/`) does dictionary lookup and gesture/glide scoring. +LeanTypeDual is an Android keyboard (an `InputMethodService` app), forked from LeanBitLab/LeanType and ultimately HeliBoard/OpenBoard/AOSP LatinIME. Its main fork addition is Nintype-style two-thumb typing. AI, OCR, handwriting and the floating keyboard are inherited upstream features, not evidence of fork authorship. The legacy input engine is **Java**; newer logic, settings, and AI code are **Kotlin**. Settings UI is **Jetpack Compose**. The bundled native **C++** engine (under `app/src/main/jni/`) handles dictionaries; gesture recognition requires a separately supplied compatible native library. **Fork lineage & "upstream":** the chain is HeliBoard (`Helium314/HeliBoard`, the original) → **`LeanBitLab/LeanType`** (a fork of HeliBoard) → **this repo, `AsafMah/LeanType`** (a fork of LeanBitLab/LeanType). When the maintainer says **"upstream" they mean `LeanBitLab/LeanType`** (`upstream/main`) — NOT HeliBoard. This fork ships as its own distinct, installable app, **"LeanTypeDual"** (its own `applicationId`, so it installs *alongside* the upstream LeanType instead of colliding with it). "Make it distinct" therefore means distinct from `LeanBitLab/LeanType`, not from HeliBoard. @@ -24,7 +24,7 @@ Strict **view → logic → engine** split. - `app/src/main/jni/` — native C++ dictionary/suggestion engine (`Android.mk`, `ndkBuild`) - `app/src/main/assets/layouts/` — layout files (subfolders are `LayoutType`: `main/`, `symbols/`, `functional/`) - `app/src/main/assets/locale_key_texts/` — per-locale popup keys (`en.txt`, …) -- `app/src/{standard,offline,offlinelite}/` — flavor-only sources (e.g. three `ProofreadService.kt` impls) +- `app/src/{standard,offline}/` — cloud and plugin-based offline sources; `standardfull` reuses standard sources. The retired offlinelite source set is forbidden by the product gate. - `app/src/test/` — JVM unit tests · `docs/` · `tools/` ## Development Commands @@ -32,12 +32,12 @@ Requires **JDK 17 or 21** and the Android SDK. On Windows use `gradlew.bat` and ```bash # Build an APK (per flavor) -./gradlew :app:assembleStandardDebug # also assembleOfflineDebug, assembleOfflineliteDebug +./gradlew :app:assembleStandardDebug # also assembleStandardfullDebug, assembleOfflineDebug # Fast CI compile check (no APK) — what PR CI runs ./gradlew compileOfflineRunTestsKotlin # Fast fork-identity/product gate (run before and after upstream merges) python tools/check_fork_invariants.py -# Packaged release gate (after all four release APKs are assembled) +# Packaged release gate (after all three release APKs are assembled) python tools/check_apk_invariants.py --apk-dir app/build/outputs/apk # Unit tests for one flavor ./gradlew :app:testOfflineDebugUnitTest @@ -62,7 +62,7 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.7-hotspot" 5. `settings/screens/.kt` — a `Setting{…}` entry added to the screen list `SettingsContainer` auto-aggregates the per-screen lists. - **State / config access:** `Settings.getValues()` returns a cached `SettingsValues` (read once, not per keystroke). Some cross-pointer state is `static` in `PointerTracker` (`sInGesture`, aggregated pointers). -- **Flavor isolation:** prefer **source-set separation** (`app/src/standard` vs `offline` vs `offlinelite`) over `BuildConfig.FLAVOR` checks. **Never** add the `INTERNET` permission to the `offline`/`offlinelite` manifests — all network activity is `standard`-only and opt-in. +- **Flavor isolation:** prefer **source-set separation** (`app/src/standard` vs `offline`) over `BuildConfig.FLAVOR` checks. **Never** add the `INTERNET` permission to the `offline` manifest. Network features are opt-in and standard/standardfull-only; Offline retains manual plugin/dictionary import. - **Performance:** the key-input and suggestion paths run on the main thread; avoid allocations in hot paths. - **IME dialogs:** an `AlertDialog` `EditText` cannot reliably receive typed input inside the IME process — intercept `onCodeInput`/`onTextInput` into a `TextView` instead (see clipboard/emoji search modes). @@ -71,7 +71,7 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.7-hotspot" - Input core: `latin/inputlogic/InputLogic.java`, `keyboard/PointerTracker.java`, `latin/RichInputConnection.java` - Dictionaries / suggestions: `latin/DictionaryFacilitatorImpl.kt`, `latin/Suggest.kt`, `app/src/main/jni/` - Settings: `latin/settings/Settings.java`, `Defaults.kt`, `SettingsValues.java`, `settings/screens/*.kt` -- Flavor AI: `app/src/standard/.../ProofreadService.kt` (Gemini), `app/src/offline/.../ProofreadService.kt` (ONNX) +- Flavor AI: `app/src/standard/.../ProofreadService.kt` (cloud providers), `app/src/offline/.../ProofreadService.kt` (upstream Offline AI plugin) - Build: `app/build.gradle.kts`, `build.gradle.kts`, `gradle.properties`, `app/proguard-rules.pro` - Docs: `docs/FEATURES.md`, `docs/TWO_THUMB_TYPING_INTERNALS.md`, `docs/IMPROVEMENT_PLAN.md`, `layouts.md`, `CONTRIBUTING.md` @@ -81,16 +81,16 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.12.7-hotspot" - **Native:** ABIs `armeabi-v7a`, `arm64-v8a`; built via `ndkBuild` (`app/src/main/jni/Android.mk`). - **Flavors** (dimension `privacy`, appId base `com.asafmah.leantypedual`): - `standard` — cloud AI (Gemini, `generativeai`), has `INTERNET`. - - `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`. + - `standardfull` — upstream Full flavor, standard sources and plugin architecture, has `INTERNET`. + - `offline` — optional upstream Offline AI plugin / GGUF, **no** `INTERNET`; appId `+.offline`, minSdk 21 (AI requires API 26). + All three exclude packaged dictionaries and use upstream's optional plugins. No bundled llama backend, retained Java gesture fallback, or separate offlinelite distribution remains. Existing imported dictionaries, models and preferences are not deleted. - **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`. ## Testing & QA - **JVM-only** (no `androidTest`/device): JUnit4 + **Robolectric 4.14.1** (simulates `LatinIME`/`Context`/prefs/key events on the JVM) + **Mockito 5.17.0**. Tests live in `app/src/test/java/helium314/keyboard/`. `testOptions.unitTests.isIncludeAndroidResources = true`. - **Run:** `./gradlew :app:testOfflineDebugUnitTest` (add `--tests "*ClassName"` for one class). -- **Upstream-merge gates:** run `python tools/check_fork_invariants.py` before and after resolving an upstream merge. Unit-test CI uses it as a fast source/configuration prefilter and fails with the specific LeanTypeDual invariant that was lost. Release CI also runs `tools/check_apk_invariants.py` after assembling all four APKs to verify the effective package IDs, minSdk values, INTERNET permissions, recursive dictionary contents, and exact artifact set. Their fixture/mutation tests run via `python -m unittest discover -s tools/tests`. +- **Upstream-merge gates:** run `python tools/check_fork_invariants.py` before and after resolving an upstream merge. The gate protects identity, privacy and dual-thumb integration while rejecting retired backends/distributions. Release CI runs `tools/check_apk_invariants.py` after assembling all three APKs to verify effective package IDs, minSdk values, INTERNET permissions, absence of bundled dictionaries, and the exact artifact set. Their fixture/mutation tests run via `python -m unittest discover -s tools/tests`. - **Key tests:** `InputLogicTest.kt` (typing/autocorrect/combining-mode/Hangul), `SuggestTest.kt`, `WordComposerTest.java`, `DictionaryGroupTest.kt` (reflection + Mockito on the package-internal `DictionaryGroup`), `SettingsContainerTest.kt` (settings wiring), `KeyboardParserTest.kt`, `ClipboardDaoTest.kt`. - **Conventions:** `@Test`; method names use camelCase or backtick form; obtain `Context` via Robolectric; package-internal classes are exercised via reflection (`Class.forName(...).declaredConstructors`). - **Known failures:** the full debug unit suite has ~11 pre-existing failures (in `KeyboardParserTest`, `XLinkTest`, `StringUtilsTest` emoji, and `InputLogicTest` Hangul/autocorrect-revert/autospace-indicator) that are environment/data-dependent and usually unrelated to a change. The `runTests` build type exists to skip these on CI. **Verify a change by diffing failures against an `origin/main` baseline run, not by absolute pass count.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b29dc8a1..8930db049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,21 +15,43 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] ### Upstream -- Merged **LeanBitLab/LeanType v4.1.8** (pinned at `3717aa80`, covering v4.1.3–v4.1.8, 178 commits) — adds sound packs, plugin/model management improvements, floating-keyboard fixes, next-word suggestion fixes, and Android compatibility updates. LeanTypeDual retains its distinct `applicationId` and version, four privacy flavors, bundled offline AI and dictionaries, Java fallback gesture engine, and two-thumb typing. (#149) +- Updated to **LeanBitLab/LeanType main at `52567c0f`** (September 7): incorporates the latest camera viewfinder, keyboard touch-region, plugin restart, and version-comparison changes. Preserves editor-session cancellation and asynchronous validated plugin imports. +- Merged **LeanBitLab/LeanType v4.2.0** (pinned at `1383390c`) — adds camera/screenshot OCR, inline math, configurable sound packs, and voice, suggestion, and AI fixes. Preserves LeanTypeDual's identity/version, two-thumb integration, and release-boundary Shift fix. Integration corrections honor the math toggle and cloud token limit, exclude unsupported AI/OCR settings, reject truncated cloud output, and retain short shortcuts and sound-settings search. CI also covers result-gate, baseline, and native-workflow-only changes. +- Merged **LeanBitLab/LeanType v4.1.8** (pinned at `3717aa80`, covering v4.1.3–v4.1.8, 178 commits) — adds sound packs, plugin/model management improvements, floating-keyboard fixes, next-word suggestion fixes, and Android compatibility updates. LeanTypeDual retains its distinct `applicationId`, version and two-thumb typing; the legacy backend/distribution retentions from that merge are removed by the current upstream alignment below. (#149) ### Added - **Side-by-side experimental build** — an `experimental` build type (`com.asafmah.leantypedual.exp`, shown as "LeanTypeDual EXP") that installs alongside the normal build instead of replacing it, so input experiments can be compared against a working daily driver. (#141) ### Fixed +- Add/Block suggestion actions use the actual word, not its displayed physical-keyboard shortcut number. (#40) +- AI actions explain when an editor cannot provide complete text or accept the requested selection. Editing text or moving the selection while AI runs still rejects stale results, but now clears that editor's loading indicator without affecting a newer request or editor. (#154) +- AI proofreading, translation and custom-key results only apply to their originating, unchanged editor/range; cancelled or superseded requests cannot deliver queued results or errors into another session. (#154) +- **Sound-pack imports reject unsafe IDs and invalid manifests** before touching installed data, and retain nested audio paths for playback. (#152) +- **Inline math respects sensitive editors and incognito history**, rejects stale chips after editor changes, preserves surrounding delimiters and whitespace, and evaluates unary operators and percentages correctly. (#152) +- **OCR work belongs to the active keyboard session**: closing, replacing, or hiding the camera cancels obsolete callbacks and releases camera resources. Capture feedback runs on the main thread, floating keyboards refresh screenshot suggestions, and OCR transitions clear persistent selection. Existing auto-insert and remembered-flash options now take effect without allowing stale results to edit another field. (#152) +- **OCR plugin replacement preserves the working plugin when validation or copying fails**, and local/downloaded imports no longer perform heavy loading on the settings UI thread. (#152) +- **Voice input no longer overrides the system microphone mute** and abandons startup cleanly when audio capture cannot start. (#152) +- **Two-finger touchpad taps survive the final finger lift**, and clipboard edit drafts retain unsaved text and selection across activity recreation. (#152) +- **Settings imports refresh active two-thumb settings immediately** and finish asynchronously without freezing the settings screen or leaving preference listeners disabled. Archives are validated before replacing selected data. (#153) +- **Dictionary changes refresh cached spelling and predictions**, including completed personal-dictionary additions. (#153) - **Fast double-taps on Shift enable Caps Lock again.** The prior duplicate-event workaround rejected legitimate taps less than 100 ms apart; distinct taps are now identified by their press/release boundary instead. (#146) - **Gesture typing no longer silently returns zero suggestions** when a stroke's touch points never carry pointer id 0 — reachable in two-thumb use (thumb A down, thumb B down, thumb A lifts, thumb B swipes on). Raw MotionEvent pointer ids are now renumbered in first-seen order. (#135, #147) ### Changed +- Adopted current upstream engine and distribution choices instead of maintaining retired upstream implementations: removed the Java gesture fallback, bundled offline AI and handwriting runtimes, and separate Offline Lite flavor. Gesture typing now requires the native gesture library; offline AI uses the optional upstream plugin. All three flavors use on-demand dictionaries, while imported dictionaries, models, preferences, app identity and live dual-thumb composition are preserved. (#148, #154) +- Removed obsolete gesture-grace and tap-seed machinery, unconsumed spacing calculations, and inactive settings including the nonfunctional hand-split slider. The live combining timer, manual composition, connector and pointer normalization are unchanged. (#16) +- Removed an unreachable duplicate blocked-words screen and a duplicate hardware-toolbar preference snapshot. The reachable blocked-words editor, stored lists and existing panel visibility behavior are unchanged. (#153) - **Removed falsified two-thumb recognition experiments from production paths.** The separate decoder-track mode, synthetic ideal-prefix trail, and their timing controls produced incorrect words with the gesture library that actually runs on devices. Multi-part composition now uses the proven pre-experiment connector path directly again. (#147) - Reframed the two-thumb decoder research as a historical record that distinguishes in-tree preprocessing facts from claims falsified against the closed runtime recognizer. (#147) ### Reliability & testing -- Added source-level and packaged-APK gates that fail upstream merges when LeanTypeDual's identity, privacy flavors, bundled offline dictionaries, fork integrations, or four-flavor release coverage are lost. (#148) +- Share the input-method fixture across Robolectric application startups and reset its mutable state between cases. Regressions cover the actual shortcut/subtype queries and automatic reset on Android 13 and 15, without suppressing asynchronous failures or relaxing the result gate. (#155) +- Bound Robolectric to one 2 GiB test worker and fail promptly on JVM memory exhaustion; preserve every test and the strict fresh-result/baseline gate. The CI failure was a worker crash with no result XML, not a reason to accept missing reports. (#155) +- AI regressions cover editor/range ownership, queued plugin feedback and the actual upstream offline provider load/generate path. Host proofreading input/output logging remains suppressed. The retired bundled-native backend and its private rebuild work are no longer part of this fork; optional external plugins retain their own runtime/privacy boundary. (#154) +- Added reproducible JVM/Robolectric regressions for unsafe imports, private/stale math acceptance, camera and OCR cancellation, screenshot visibility, microphone mute, touchpad tap sequences, and clipboard draft restoration. Camera hardware and native plugin behavior still require device validation. (#152) +- Added deterministic backup/restore regressions for slow providers, lifecycle disposal, rejected archives, and immediate two-thumb settings refresh. (#153) +- Added controlled dictionary lifecycle regressions for mutation publication, provider changes during reload, shutdown, and cached predictions. Removed cases that existed only for the retired Java fallback index. (#153) +- Source and packaged-APK gates protect identity, offline privacy, dual-thumb integration, three-flavor release/signature coverage and on-demand dictionary packaging. Mutation fixtures reject reintroducing retired backends or Offline Lite. Native-routing regressions cover saved fallback preferences and missing native libraries. (#148) - Kept the independently useful pointer-id normalization regression coverage, added production-wiring coverage for the no-id-0 case, and pinned the restored connector's exact coordinates, timestamps, and pointer ids. Removed the native research harness whose results were easy to mistake for runtime recognizer behavior. (#147) ## [0.3.0] - 2026-08-20 diff --git a/README.md b/README.md index b8ba433bc..e6ab9110a 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,18 @@ The **"Dual"** is **dual-thumb gesture typing**: glide with both thumbs at once ## What makes LeanTypeDual different ### ✌️ Two-thumb (dual-thumb) typing — the namesake feature -Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates multiple simultaneous gesture trails into a single word (a Nintype-style flow) instead of forcing one-finger-at-a-time swipes. It has a dedicated tuning screen — combining-mode grace timing, tap-promotion, fragment backspace (pop the last swiped fragment), multi-part word recognition, customizable autospace, and an opt-in typing-insight overlay that visualizes the gesture join. *(Gesture typing requires the gesture library — see Download.)* +Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates multiple simultaneous gesture trails into a single word (a Nintype-style flow) instead of forcing one-finger-at-a-time swipes. It has a dedicated tuning screen — combining-mode grace timing, tap/swipe composition, fragment backspace (pop the last swiped fragment), multi-part word recognition, customizable autospace, and an opt-in typing-insight overlay that visualizes the gesture join. *(Gesture typing requires the gesture library — see Download.)* ### On top of that — LeanType's AI layer and quality-of-life features +- **Camera and screenshot OCR** - Extract and format text on-device with an optional OCR plugin and user-granted camera/media access (Android 8.0+). +- **Inline math** - Type an arithmetic expression followed by `=` to offer its result in the suggestion strip. +- **Custom sound packs** - Import sound-pack ZIPs and tune keypress audio; Standard/Full can download packs in-app, while offline tiers use browser downloads and local import. - **[🤖 Multi-Provider AI](docs/FEATURES.md#supported-ai-providers)** - Proofread using **Gemini**, **Groq** (Llama 3, Mixtral), or **OpenAI-compatible** providers, with dynamic fetching of the latest models. -- **[🛡️ Offline AI (GGUF)](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using local **GGUF models** powered by `llama.cpp` (Offline build only). +- **[🛡️ Offline AI (GGUF)](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - On-device proofreading and translation using local **GGUF models** and the optional upstream Offline AI plugin (Offline build, Android 8.0+). The keyboard no longer bundles its own AI runtime. - **🌐 AI Translation** - Translate selected text using your chosen provider, with a separate model selector. - **[✍️ Handwriting Input](docs/FEATURES.md#8-handwriting-input)** - Draw characters directly on a handwriting recognition canvas (Standard version, requires [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin)). -- **[👆 Built-in Gesture Typing](docs/FEATURES.md#9-built-in-gesture-typing)** - Gesture typing works out of the box using our new built-in pure-Java fallback engine, removing the strict dependency on native Google libraries. +- **[👆 Gesture Typing](docs/FEATURES.md#9-gesture-typing)** - Single- and dual-thumb gestures use a compatible native gesture library. Install it through Settings before swiping; the older Java fallback engine has been removed. - **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and labels/tags (themed capsules) to 10 customizable toolbar keys. - **📝 Text Expander** - Shortcut → expansion with dynamic placeholders (`%clipboard%`, `%day%`, `%time12%`, `%cursor%`, lists), regex shortcuts, backspace-to-revert, and a guide. - **🧠 Smarter learned words** - *graduated trust* keeps a just-learned word below real-dictionary suggestions until you've used it a few times (no premature autocorrect to half-typed words); flag unknown words to **Add** or **Block** them via a Blocklist screen. @@ -46,7 +49,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **💾 Selective Backup & Restore** - Backup and restore settings, dictionaries, and AI prompt configuration selectively. - **🔎 Emoji Search** - Search emojis by name. *Requires loading an Emoji Dictionary.* - **⚙️ Enhanced Customization** - Force auto-capitalization, fine-grained haptics, distinct incognito icon, reorganized settings, and more. -- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI, handwriting), **Offline** (network hard-disabled, offline GGUF model), or **Offline Lite** (no AI, ~20 MB). +- **🔒 Privacy Choices** - Choose **Standard / Standard Full** (opt-in network features) or **Offline** (no INTERNET permission, optional local plugins). Dictionaries are acquired separately in every build. @@ -91,19 +94,20 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult * **Setup:** Use the built-in downloader for Gesture Typing and Handwriting Input. Configure AI keys in Settings. #### 2. Offline Version (`-offline-release.apk`) -* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (via `llama.cpp` using local **GGUF models**). +* **Features:** The keyboard supports Android 5.0+. Optional **Offline Neural Proofreading** requires Android 8.0+, the upstream Offline AI plugin, and local **GGUF models**. * **Permissions:** **NO INTERNET PERMISSION**. Guaranteed at OS level. * **Best For:** Privacy purists. * **Manual Setup Required:** * **Gesture Typing:** [Download library manually](https://github.com/erkserkserks/openboard/tree/46fdf2b550035ca69299ce312fa158e7ade36967/app/src/main/jniLibs) and load via *Settings > Gesture typing*. - * **Offline AI:** Download GGUF models and load via *Settings > Advanced > GGUF Model (.gguf)*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** + * **Offline AI:** Import the upstream Offline AI plugin through *Settings > Libraries Hub*, then load a GGUF model through AI settings. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** + * **Dictionaries:** Download dictionaries in a browser and import them through dictionary settings. Existing imported dictionaries are preserved. -#### 3. Offline Lite Version (`-offlinelite-release.apk`) -* **Features:** All UI/UX enhancements but **NO AI FEATURES**. -* **Permissions:** **NO INTERNET PERMISSION**. Guaranteed at OS level. -* **Best For:** Minimalists who want a modern keyboard without any AI components (~20MB size). -* **Manual Setup Required:** - * **Gesture Typing:** [Download library manually](https://github.com/erkserkserks/openboard/tree/46fdf2b550035ca69299ce312fa158e7ade36967/app/src/main/jniLibs) and load via *Settings > Gesture typing*. +#### 3. Standard Full Version (`-standardfull-release.apk`) +* **Features and permissions:** Uses the same current upstream plugin architecture and opt-in network features as Standard. Optional handwriting, translation and OCR runtimes are supplied by their plugins, not retained copies in the keyboard. + +**Offline Lite is retired**, following upstream's unified Offline distribution. Existing Lite installations are not removed or silently migrated to a different app package. Back up settings and dictionaries before moving to Offline. + +All three builds use on-demand dictionaries. Standard/Full can download them in-app; Offline uses browser downloads and local import. Removing bundled assets does not delete dictionaries already stored in app data. ## Original HeliBoard Features @@ -112,7 +116,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
  • Customize keyboard themes (style, colors and background image)
  • Customize keyboard layouts
  • Multilingual typing
  • -
  • Glide typing (works out of the box with built-in pure-Java fallback engine, or use native library)
  • +
  • Glide typing (requires a compatible native gesture library)
  • Clipboard history
  • One-handed mode
  • Split keyboard
  • diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 812b73d01..62a50ff77 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,11 +51,7 @@ android { create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" - minSdk = 26 - } - create("offlinelite") { - dimension = "privacy" - applicationIdSuffix = ".offlinelite" + minSdk = 21 } } @@ -127,7 +123,6 @@ android { "standard" -> "1" "standardfull" -> "1" "offline" -> "2" - "offlinelite" -> "3" else -> "" } if (number.isNotEmpty()) { @@ -148,14 +143,12 @@ android { variant.proguardFiles.add(project.layout.buildDirectory.file(getDefaultProguardFile("proguard-android.txt").absolutePath)) variant.proguardFiles.add(project.layout.buildDirectory.file(project.buildFile.parent + "/proguard-rules.pro")) } - if (variant.flavorName == "standard" || variant.flavorName == "standardfull") { - // Ignore all dictionary assets in standard/standardfull flavors - val dictsDir = project.file("src/main/assets/dicts") - if (dictsDir.exists() && dictsDir.isDirectory) { - dictsDir.listFiles()?.forEach { file -> - if (file.name.endsWith(".dict")) { - patterns.add(file.name) - } + // Dictionaries are downloaded on demand, as in upstream. + val dictsDir = project.file("src/main/assets/dicts") + if (dictsDir.exists() && dictsDir.isDirectory) { + dictsDir.listFiles()?.forEach { file -> + if (file.name.endsWith(".dict")) { + patterns.add(file.name) } } } @@ -206,6 +199,12 @@ android { testOptions { unitTests { isIncludeAndroidResources = true + all { + // Robolectric's multi-SDK suite outgrows Gradle's default 512 MB test heap. + it.maxHeapSize = "2g" + it.maxParallelForks = 1 + it.jvmArgs("-XX:+ExitOnOutOfMemoryError") + } } } @@ -268,22 +267,23 @@ dependencies { "standardfullImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") "standardfullImplementation"("androidx.security:security-crypto:1.1.0-alpha06") - // local llm proofreading (offline) - "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") + // Offline AI is supplied by the upstream plugin. // Force 16 KB page-aligned version of graphics-path implementation("androidx.graphics:graphics-path:1.1.0") - // WorkManager — required by ML Kit Digital Ink plugin (loaded via DexClassLoader). + // CameraX for in-keyboard OCR viewfinder + val cameraxVersion = "1.4.1" + implementation("androidx.camera:camera-core:$cameraxVersion") + implementation("androidx.camera:camera-camera2:$cameraxVersion") + implementation("androidx.camera:camera-lifecycle:$cameraxVersion") + implementation("androidx.camera:camera-view:$cameraxVersion") + + // WorkManager — required by plugins loaded via DexClassLoader. // ML Kit internally calls WorkManager.getInstance(context) using the host app context, // so the host app must have WorkManagerInitializer registered in its manifest. implementation("androidx.work:work-runtime-ktx:2.10.1") - // ML Kit Digital Ink Recognition — required by the handwriting plugin. - // ML Kit's internal asset manager and native library loader use the host app context, - // so the host app must compile and include the client library resources/libraries. - "standardfullImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") - // test testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ad216078e..1aa59153e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -74,6 +74,13 @@ -keep class helium314.keyboard.latin.ai.** { *; } -keep interface helium314.keyboard.latin.ai.** { *; } +# Keep OCR plugin interface and classes to prevent parameter removal or signature optimization +-keep interface helium314.keyboard.latin.ocr.ITextRecognizer { + ; +} +-keep class helium314.keyboard.latin.ocr.** { *; } +-keep interface helium314.keyboard.latin.ocr.** { *; } + # Keep WorkManager plugin factory & runtime for dynamically loaded plugins -keep class helium314.keyboard.latin.work.** { *; } -keep interface helium314.keyboard.latin.work.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e5123e720..43fd20953 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,8 +18,11 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + + + diff --git a/app/src/main/assets/sounds/arcade_8bit/delete.ogg b/app/src/main/assets/sounds/arcade_8bit/delete.ogg deleted file mode 100644 index 8e4a070b8..000000000 Binary files a/app/src/main/assets/sounds/arcade_8bit/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/arcade_8bit/enter.ogg b/app/src/main/assets/sounds/arcade_8bit/enter.ogg deleted file mode 100644 index c32872336..000000000 Binary files a/app/src/main/assets/sounds/arcade_8bit/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/arcade_8bit/space.ogg b/app/src/main/assets/sounds/arcade_8bit/space.ogg deleted file mode 100644 index 8389a42cf..000000000 Binary files a/app/src/main/assets/sounds/arcade_8bit/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/arcade_8bit/standard.ogg b/app/src/main/assets/sounds/arcade_8bit/standard.ogg deleted file mode 100644 index cf6e9ddd4..000000000 Binary files a/app/src/main/assets/sounds/arcade_8bit/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/ios/delete.ogg b/app/src/main/assets/sounds/ios/delete.ogg deleted file mode 100644 index 13d0e88cc..000000000 Binary files a/app/src/main/assets/sounds/ios/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/ios/enter.ogg b/app/src/main/assets/sounds/ios/enter.ogg deleted file mode 100644 index f3e2a1b89..000000000 Binary files a/app/src/main/assets/sounds/ios/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/ios/space.ogg b/app/src/main/assets/sounds/ios/space.ogg deleted file mode 100644 index 017e22d17..000000000 Binary files a/app/src/main/assets/sounds/ios/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/ios/standard.ogg b/app/src/main/assets/sounds/ios/standard.ogg deleted file mode 100644 index 382502cc4..000000000 Binary files a/app/src/main/assets/sounds/ios/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/laser_scifi/delete.ogg b/app/src/main/assets/sounds/laser_scifi/delete.ogg deleted file mode 100644 index 71a8da8af..000000000 Binary files a/app/src/main/assets/sounds/laser_scifi/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/laser_scifi/enter.ogg b/app/src/main/assets/sounds/laser_scifi/enter.ogg deleted file mode 100644 index 80830c899..000000000 Binary files a/app/src/main/assets/sounds/laser_scifi/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/laser_scifi/space.ogg b/app/src/main/assets/sounds/laser_scifi/space.ogg deleted file mode 100644 index 4de67a913..000000000 Binary files a/app/src/main/assets/sounds/laser_scifi/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/laser_scifi/standard.ogg b/app/src/main/assets/sounds/laser_scifi/standard.ogg deleted file mode 100644 index 91c203403..000000000 Binary files a/app/src/main/assets/sounds/laser_scifi/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/marimba_tone/delete.ogg b/app/src/main/assets/sounds/marimba_tone/delete.ogg deleted file mode 100644 index 001b931ee..000000000 Binary files a/app/src/main/assets/sounds/marimba_tone/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/marimba_tone/enter.ogg b/app/src/main/assets/sounds/marimba_tone/enter.ogg deleted file mode 100644 index c04fc1ece..000000000 Binary files a/app/src/main/assets/sounds/marimba_tone/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/marimba_tone/space.ogg b/app/src/main/assets/sounds/marimba_tone/space.ogg deleted file mode 100644 index c8e4d06c5..000000000 Binary files a/app/src/main/assets/sounds/marimba_tone/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/marimba_tone/standard.ogg b/app/src/main/assets/sounds/marimba_tone/standard.ogg deleted file mode 100644 index c70bcf37d..000000000 Binary files a/app/src/main/assets/sounds/marimba_tone/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_cherry/delete.ogg b/app/src/main/assets/sounds/mechanical_cherry/delete.ogg deleted file mode 100644 index e13f03638..000000000 Binary files a/app/src/main/assets/sounds/mechanical_cherry/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_cherry/enter.ogg b/app/src/main/assets/sounds/mechanical_cherry/enter.ogg deleted file mode 100644 index f43e5df10..000000000 Binary files a/app/src/main/assets/sounds/mechanical_cherry/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_cherry/space.ogg b/app/src/main/assets/sounds/mechanical_cherry/space.ogg deleted file mode 100644 index 33c12648e..000000000 Binary files a/app/src/main/assets/sounds/mechanical_cherry/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_cherry/standard.ogg b/app/src/main/assets/sounds/mechanical_cherry/standard.ogg deleted file mode 100644 index 227815cca..000000000 Binary files a/app/src/main/assets/sounds/mechanical_cherry/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_thock/delete.ogg b/app/src/main/assets/sounds/mechanical_thock/delete.ogg deleted file mode 100644 index b73093f80..000000000 Binary files a/app/src/main/assets/sounds/mechanical_thock/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_thock/enter.ogg b/app/src/main/assets/sounds/mechanical_thock/enter.ogg deleted file mode 100644 index c36da6029..000000000 Binary files a/app/src/main/assets/sounds/mechanical_thock/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_thock/space.ogg b/app/src/main/assets/sounds/mechanical_thock/space.ogg deleted file mode 100644 index 2babfece4..000000000 Binary files a/app/src/main/assets/sounds/mechanical_thock/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/mechanical_thock/standard.ogg b/app/src/main/assets/sounds/mechanical_thock/standard.ogg deleted file mode 100644 index d95250ede..000000000 Binary files a/app/src/main/assets/sounds/mechanical_thock/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/modern_tick/delete.ogg b/app/src/main/assets/sounds/modern_tick/delete.ogg deleted file mode 100644 index 01fd95187..000000000 Binary files a/app/src/main/assets/sounds/modern_tick/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/modern_tick/enter.ogg b/app/src/main/assets/sounds/modern_tick/enter.ogg deleted file mode 100644 index 120a1407f..000000000 Binary files a/app/src/main/assets/sounds/modern_tick/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/modern_tick/space.ogg b/app/src/main/assets/sounds/modern_tick/space.ogg deleted file mode 100644 index 88c36e3e5..000000000 Binary files a/app/src/main/assets/sounds/modern_tick/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/modern_tick/standard.ogg b/app/src/main/assets/sounds/modern_tick/standard.ogg deleted file mode 100644 index d0af8c24e..000000000 Binary files a/app/src/main/assets/sounds/modern_tick/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/pop_bubble/delete.ogg b/app/src/main/assets/sounds/pop_bubble/delete.ogg deleted file mode 100644 index 09b0c826a..000000000 Binary files a/app/src/main/assets/sounds/pop_bubble/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/pop_bubble/enter.ogg b/app/src/main/assets/sounds/pop_bubble/enter.ogg deleted file mode 100644 index de20ab849..000000000 Binary files a/app/src/main/assets/sounds/pop_bubble/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/pop_bubble/space.ogg b/app/src/main/assets/sounds/pop_bubble/space.ogg deleted file mode 100644 index bd217a197..000000000 Binary files a/app/src/main/assets/sounds/pop_bubble/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/pop_bubble/standard.ogg b/app/src/main/assets/sounds/pop_bubble/standard.ogg deleted file mode 100644 index d069b76f9..000000000 Binary files a/app/src/main/assets/sounds/pop_bubble/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/retro_terminal/delete.ogg b/app/src/main/assets/sounds/retro_terminal/delete.ogg deleted file mode 100644 index 0731df672..000000000 Binary files a/app/src/main/assets/sounds/retro_terminal/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/retro_terminal/enter.ogg b/app/src/main/assets/sounds/retro_terminal/enter.ogg deleted file mode 100644 index 8ea3b7415..000000000 Binary files a/app/src/main/assets/sounds/retro_terminal/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/retro_terminal/space.ogg b/app/src/main/assets/sounds/retro_terminal/space.ogg deleted file mode 100644 index 4214dd042..000000000 Binary files a/app/src/main/assets/sounds/retro_terminal/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/retro_terminal/standard.ogg b/app/src/main/assets/sounds/retro_terminal/standard.ogg deleted file mode 100644 index 847e92de3..000000000 Binary files a/app/src/main/assets/sounds/retro_terminal/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/soft_pudding/delete.ogg b/app/src/main/assets/sounds/soft_pudding/delete.ogg deleted file mode 100644 index 7e8afb0bb..000000000 Binary files a/app/src/main/assets/sounds/soft_pudding/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/soft_pudding/enter.ogg b/app/src/main/assets/sounds/soft_pudding/enter.ogg deleted file mode 100644 index 662cc425f..000000000 Binary files a/app/src/main/assets/sounds/soft_pudding/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/soft_pudding/space.ogg b/app/src/main/assets/sounds/soft_pudding/space.ogg deleted file mode 100644 index 05fc2601b..000000000 Binary files a/app/src/main/assets/sounds/soft_pudding/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/soft_pudding/standard.ogg b/app/src/main/assets/sounds/soft_pudding/standard.ogg deleted file mode 100644 index b9b69fd07..000000000 Binary files a/app/src/main/assets/sounds/soft_pudding/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/vintage_typewriter/delete.ogg b/app/src/main/assets/sounds/vintage_typewriter/delete.ogg deleted file mode 100644 index 22d46dc05..000000000 Binary files a/app/src/main/assets/sounds/vintage_typewriter/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/vintage_typewriter/enter.ogg b/app/src/main/assets/sounds/vintage_typewriter/enter.ogg deleted file mode 100644 index bd630fc96..000000000 Binary files a/app/src/main/assets/sounds/vintage_typewriter/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/vintage_typewriter/space.ogg b/app/src/main/assets/sounds/vintage_typewriter/space.ogg deleted file mode 100644 index 6339e41d8..000000000 Binary files a/app/src/main/assets/sounds/vintage_typewriter/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/vintage_typewriter/standard.ogg b/app/src/main/assets/sounds/vintage_typewriter/standard.ogg deleted file mode 100644 index 68403b09e..000000000 Binary files a/app/src/main/assets/sounds/vintage_typewriter/standard.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/wood_minimal/delete.ogg b/app/src/main/assets/sounds/wood_minimal/delete.ogg deleted file mode 100644 index 0d27a18d5..000000000 Binary files a/app/src/main/assets/sounds/wood_minimal/delete.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/wood_minimal/enter.ogg b/app/src/main/assets/sounds/wood_minimal/enter.ogg deleted file mode 100644 index 6c0b38b4c..000000000 Binary files a/app/src/main/assets/sounds/wood_minimal/enter.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/wood_minimal/space.ogg b/app/src/main/assets/sounds/wood_minimal/space.ogg deleted file mode 100644 index 707fe8381..000000000 Binary files a/app/src/main/assets/sounds/wood_minimal/space.ogg and /dev/null differ diff --git a/app/src/main/assets/sounds/wood_minimal/standard.ogg b/app/src/main/assets/sounds/wood_minimal/standard.ogg deleted file mode 100644 index 6f546ee04..000000000 Binary files a/app/src/main/assets/sounds/wood_minimal/standard.ogg and /dev/null differ diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index daeb1b06b..721f0b247 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -158,6 +158,14 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } return } + KeyCode.OCR -> { + if (keyboardSwitcher.isOcrShowing) { + keyboardSwitcher.hideOcrPanels() + } else { + keyboardSwitcher.showOcrCamera() + } + return + } KeyCode.TOGGLE_AUTOCORRECT -> { settings.toggleAutoCorrect() latinIME.onOneShotSpaceActionStateChanged() diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 14819b7c5..6c2681e12 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -44,6 +44,9 @@ import helium314.keyboard.latin.WordComposer; import helium314.keyboard.latin.handwriting.HandwritingLoader; import helium314.keyboard.latin.handwriting.HandwritingView; +import helium314.keyboard.latin.ocr.OcrCameraView; +import helium314.keyboard.latin.ocr.OcrResultView; +import helium314.keyboard.latin.ocr.OcrPluginLoader; import helium314.keyboard.latin.common.ColorType; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; @@ -57,6 +60,7 @@ import helium314.keyboard.latin.utils.ScriptUtils; import helium314.keyboard.latin.utils.SubtypeUtilsAdditional; import helium314.keyboard.latin.utils.ToolbarMode; +import java.util.List; public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private static final String TAG = KeyboardSwitcher.class.getSimpleName(); @@ -69,10 +73,14 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private View mEmojiTabStripView; private LinearLayout mClipboardStripView; private HorizontalScrollView mClipboardStripScrollView; + private LinearLayout mOcrStripView; + private HorizontalScrollView mOcrStripScrollView; private SuggestionStripView mSuggestionStripView; private LinearLayout mStripContainer; private ClipboardHistoryView mClipboardHistoryView; private HandwritingView mHandwritingView; + private OcrCameraView mOcrCameraView; + private OcrResultView mOcrResultView; private TouchpadView mTouchpadView; private TextView mFakeToastView; private LatinIME mLatinIME; @@ -208,6 +216,7 @@ public void onHideWindow() { if (mKeyboardView != null) { mKeyboardView.onHideWindow(); } + cancelOcrWork(); } public void onConfigurationChanged(final Configuration newConfig) { @@ -373,8 +382,25 @@ public boolean isImeSuppressedByHardwareKeyboard( private void setMainKeyboardFrame( @NonNull final SettingsValues settingsValues, @NonNull final KeyboardSwitchState toggleState) { + if (isOcrShowing()) { + if (mKeyboardView != null) { + mKeyboardView.setVisibility(View.INVISIBLE); + mKeyboardView.setClickable(false); + mKeyboardView.setFocusable(false); + } + if (mOcrCameraView != null && mOcrCameraView.isShown()) { + mOcrCameraView.bringToFront(); + } else if (mOcrResultView != null && mOcrResultView.isShown()) { + mOcrResultView.bringToFront(); + } + if (mCurrentInputView != null) { + mCurrentInputView.post(mCurrentInputView::requestApplyInsets); + } + return; + } + cancelOcrWork(); final boolean suppressKeyboard = isImeSuppressedByHardwareKeyboard(settingsValues, toggleState) - || (settingsValues.mShowOnlyToolbarWithHardwareKeyboard && settingsValues.mHasHardwareKeyboard); + || settingsValues.mShowToolbarOnly; final int visibility = suppressKeyboard ? View.GONE : View.VISIBLE; final int stripVisibility = settingsValues.mToolbarMode == ToolbarMode.HIDDEN ? View.GONE : View.VISIBLE; mStripContainer.setVisibility(stripVisibility); @@ -395,6 +421,7 @@ private void setMainKeyboardFrame( mEmojiPalettesView.stopEmojiPalettes(); mEmojiTabStripView.setVisibility(View.GONE); mClipboardStripScrollView.setVisibility(View.GONE); + if (mOcrStripScrollView != null) mOcrStripScrollView.setVisibility(View.GONE); mSuggestionStripView.setVisibility(stripVisibility); mClipboardHistoryView.setVisibility(View.GONE); mClipboardHistoryView.stopClipboardHistory(); @@ -404,6 +431,12 @@ private void setMainKeyboardFrame( } mHandwritingView.setVisibility(View.GONE); } + if (mOcrCameraView != null) { + mOcrCameraView.setVisibility(View.GONE); + } + if (mOcrResultView != null) { + mOcrResultView.setVisibility(View.GONE); + } if (PointerTracker.sPersistentTouchpadModeActive) { if (mTouchpadView != null) { @@ -429,6 +462,7 @@ private static void clearTextEditModeState() { // Implements {@link KeyboardState.SwitchActions}. @Override public void setEmojiKeyboard() { + cancelOcrWork(); if (DEBUG_ACTION) { Log.d(TAG, "setEmojiKeyboard"); } @@ -461,6 +495,7 @@ public void setEmojiKeyboard() { // Implements {@link KeyboardState.SwitchActions}. @Override public void setClipboardKeyboard() { + cancelOcrWork(); if (DEBUG_ACTION) { Log.d(TAG, "setClipboardKeyboard"); } @@ -490,6 +525,7 @@ public void setClipboardKeyboard() { } public void setHandwritingKeyboard() { + cancelOcrWork(); if (DEBUG_ACTION) { Log.d(TAG, "setHandwritingKeyboard"); } @@ -530,6 +566,145 @@ public void clearHandwritingCanvas() { } } + public void showOcrCamera() { + if (DEBUG_ACTION) { + Log.d(TAG, "showOcrCamera"); + } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } + clearTextEditModeState(); + cancelOcrWork(); + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.INVISIBLE); + mKeyboardView.setClickable(false); + mKeyboardView.setFocusable(false); + mEmojiTabStripView.setVisibility(View.GONE); + mSuggestionStripView.setVisibility(View.GONE); + mStripContainer.setVisibility(View.GONE); + mClipboardStripScrollView.setVisibility(View.GONE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + if (mHandwritingView != null) { + if (mHandwritingView.isShown()) { + mHandwritingView.stopHandwriting(); + } + mHandwritingView.setVisibility(View.GONE); + } + if (mOcrResultView != null) { + mOcrResultView.setVisibility(View.GONE); + } + if (mOcrCameraView != null) { + final int ocrCameraHeight = ResourceUtils.getOcrCameraHeight(mThemeContext.getResources(), Settings.getValues()); + final android.view.ViewGroup.LayoutParams lp = mOcrCameraView.getLayoutParams(); + if (lp != null) { + lp.height = ocrCameraHeight; + mOcrCameraView.setLayoutParams(lp); + } + mOcrCameraView.setVisibility(View.VISIBLE); + mOcrCameraView.bringToFront(); + mOcrCameraView.startCamera(); + } + requestInputViewLayoutAndInsets(); + } + + public void showOcrResult(@NonNull final List lines) { + if (DEBUG_ACTION) { + Log.d(TAG, "showOcrResult"); + } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } + clearTextEditModeState(); + cancelOcrWork(); + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.INVISIBLE); + mKeyboardView.setClickable(false); + mKeyboardView.setFocusable(false); + mEmojiTabStripView.setVisibility(View.GONE); + mSuggestionStripView.setVisibility(View.GONE); + mClipboardStripScrollView.setVisibility(View.GONE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + if (mHandwritingView != null) { + if (mHandwritingView.isShown()) { + mHandwritingView.stopHandwriting(); + } + mHandwritingView.setVisibility(View.GONE); + } + if (mOcrStripScrollView != null) { + Settings.getValues().mColors.setBackground(mOcrStripScrollView, ColorType.STRIP_BACKGROUND); + mOcrStripScrollView.setVisibility(View.VISIBLE); + } + mStripContainer.setVisibility(View.VISIBLE); + if (mOcrCameraView != null) { + mOcrCameraView.setVisibility(View.GONE); + } + if (mOcrResultView != null) { + final int keyboardHeight = ResourceUtils.getKeyboardHeight(mThemeContext.getResources(), Settings.getValues()); + final android.view.ViewGroup.LayoutParams lp = mOcrResultView.getLayoutParams(); + if (lp != null) { + lp.height = keyboardHeight; + mOcrResultView.setLayoutParams(lp); + } + mOcrResultView.setResultText(lines); + mOcrResultView.applyColors(Settings.getValues().mColors); + mOcrResultView.setVisibility(View.VISIBLE); + mOcrResultView.bringToFront(); + } + requestInputViewLayoutAndInsets(); + } + + public void hideOcrPanels() { + clearTextEditModeState(); + cancelOcrWork(); + if (mOcrStripScrollView != null) { + mOcrStripScrollView.setVisibility(View.GONE); + } + mSuggestionStripView.setVisibility(View.VISIBLE); + mStripContainer.setVisibility(View.VISIBLE); + if (mOcrCameraView != null) { + mOcrCameraView.setVisibility(View.GONE); + } + if (mOcrResultView != null) { + mOcrResultView.setVisibility(View.GONE); + } + if (mKeyboardView != null) { + mKeyboardView.setVisibility(View.VISIBLE); + mKeyboardView.setClickable(true); + mKeyboardView.setFocusable(true); + } + requestInputViewLayoutAndInsets(); + setAlphabetKeyboard(); + } + + private void requestInputViewLayoutAndInsets() { + if (mCurrentInputView != null) { + if (mCurrentInputView.isInLayout()) { + mCurrentInputView.post(mCurrentInputView::requestLayout); + } else { + mCurrentInputView.requestLayout(); + } + mCurrentInputView.post(mCurrentInputView::requestApplyInsets); + } + } + + public boolean isOcrCameraShowing() { + return mOcrCameraView != null && (mOcrCameraView.isShown() || mOcrCameraView.getVisibility() == View.VISIBLE); + } + + public boolean isOcrShowing() { + return isOcrCameraShowing() + || (mOcrResultView != null && (mOcrResultView.isShown() || mOcrResultView.getVisibility() == View.VISIBLE)); + } + + public void cancelOcrWork() { + if (mOcrCameraView != null) mOcrCameraView.stopCamera(); + if (mLatinIME != null) mLatinIME.getClipboardHistoryManager().cancelScreenshotOcr(); + } + @Override public void setNumpadKeyboard() { if (DEBUG_ACTION) { @@ -883,7 +1058,11 @@ public EmojiPalettesView getEmojiPalettesView() { } public View getVisibleKeyboardView() { - if (isShowingEmojiPalettes()) { + if (isOcrCameraShowing()) { + return mOcrCameraView; + } else if (mOcrResultView != null && (mOcrResultView.isShown() || mOcrResultView.getVisibility() == View.VISIBLE)) { + return mOcrResultView; + } else if (isShowingEmojiPalettes()) { return mEmojiPalettesView; } else if (isShowingClipboardHistory()) { return mClipboardHistoryView; @@ -911,6 +1090,14 @@ public HorizontalScrollView getClipboardStripScrollView() { return mClipboardStripScrollView; } + public LinearLayout getOcrStrip() { + return mOcrStripView; + } + + public HorizontalScrollView getOcrStripScrollView() { + return mOcrStripScrollView; + } + public MainKeyboardView getMainKeyboardView() { return mKeyboardView; } @@ -924,6 +1111,7 @@ public LinearLayout getStripContainer() { } public void deallocateMemory() { + cancelOcrWork(); if (mKeyboardView != null) { mKeyboardView.cancelAllOngoingEvents(); mKeyboardView.deallocateMemory(); @@ -934,6 +1122,9 @@ public void deallocateMemory() { if (mClipboardHistoryView != null) { mClipboardHistoryView.stopClipboardHistory(); } + if (mOcrCameraView != null) { + mOcrCameraView.release(); + } } public void trimMemory() { @@ -949,6 +1140,11 @@ public void trimMemory() { @SuppressLint("InflateParams") public View onCreateInputView(@NonNull Context displayContext, final boolean isHardwareAcceleratedDrawingEnabled) { + cancelOcrWork(); + if (mOcrCameraView != null) { + mOcrCameraView.release(); + mOcrCameraView = null; + } if (mCurrentInputView != null) { mCurrentInputView.removeAllViews(); } @@ -972,8 +1168,50 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mEmojiPalettesView = mCurrentInputView.findViewById(R.id.emoji_palettes_view); mClipboardHistoryView = mCurrentInputView.findViewById(R.id.clipboard_history_view); mHandwritingView = mCurrentInputView.findViewById(R.id.handwriting_view); + mOcrCameraView = mCurrentInputView.findViewById(R.id.ocr_camera_view); + mOcrResultView = mCurrentInputView.findViewById(R.id.ocr_result_view); mFakeToastView = mCurrentInputView.findViewById(R.id.fakeToast); + if (mOcrCameraView != null) { + mOcrCameraView.setListener(new OcrCameraView.OcrViewListener() { + @Override + public void onOcrTextExtracted(@NonNull List lines) { + showOcrResult(lines); + } + + @Override + public void onOcrTextInserted(@NonNull String text) { + if (!mLatinIME.getCurrentInputStarted() || mLatinIME.getCurrentInputEditorInfo() == null) return; + mLatinIME.onTextInput(text); + hideOcrPanels(); + } + + @Override + public void onCloseOcr() { + hideOcrPanels(); + } + }); + } + if (mOcrResultView != null) { + mOcrResultView.setListener(new OcrResultView.OcrResultListener() { + @Override + public void onInsertText(@NonNull String text) { + mLatinIME.onTextInput(text); + hideOcrPanels(); + } + + @Override + public void onRetake() { + showOcrCamera(); + } + + @Override + public void onClose() { + hideOcrPanels(); + } + }); + } + mKeyboardViewWrapper = mCurrentInputView.findViewById(R.id.keyboard_view_wrapper); mKeyboardViewWrapper.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); mKeyboardView = mCurrentInputView.findViewById(R.id.keyboard_view); @@ -989,6 +1227,8 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mEmojiTabStripView = mCurrentInputView.findViewById(R.id.emoji_tab_strip); mClipboardStripView = mCurrentInputView.findViewById(R.id.clipboard_strip); mClipboardStripScrollView = mCurrentInputView.findViewById(R.id.clipboard_strip_scroll_view); + mOcrStripView = mCurrentInputView.findViewById(R.id.ocr_strip); + mOcrStripScrollView = mCurrentInputView.findViewById(R.id.ocr_strip_scroll_view); mSuggestionStripView = mCurrentInputView.findViewById(R.id.suggestion_strip_view); mStripContainer = mCurrentInputView.findViewById(R.id.strip_container); diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 2b040d7df..71d6920be 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -105,8 +105,7 @@ public final class MainKeyboardView extends KeyboardView implements DrawingProxy private final Drawable mIncognitoIcon; // --- Two-thumb typing: combining-mode visual -------------------------------------------- // While the unified combining-mode grace timer is pending in InputLogic, we draw a - // countdown progress bar at the bottom of the space bar AND a faint translucent tint - // over the whole keyboard to reinforce "next input extends the current word". A + // countdown progress bar at the bottom of the space bar. A // ValueAnimator drives invalidations at ~60fps so the bar shrinks smoothly; on cancel // / timer-expiry we set mCombiningModeActive=false and the bar disappears next frame. private boolean mCombiningModeActive = false; @@ -316,11 +315,9 @@ public void setLanguageOnSpacebarAnimAlpha(final int alpha) { } /** - * Combining mode (replaces the older PREF_AUTOSPACE_VISUAL_HINT flash): turn the + * Combining mode: turn the * progress-bar indicator on the spacebar on/off. While on, a countdown bar shrinks - * from full width at {@code startTimeMs} to zero at {@code startTimeMs + graceMs}; - * the keyboard ALSO draws a faint translucent tint over the whole view to signal - * "your next input extends the current word". + * from full width at {@code startTimeMs} to zero at {@code startTimeMs + graceMs}. * *

    Called from {@link helium314.keyboard.latin.inputlogic.InputLogic} on every * tap / gesture completion (active=true) and on commit / cancel (active=false). @@ -342,9 +339,6 @@ public void setCombiningMode(final boolean active, final long startTimeMs, final mCombiningCompositionActiveForDebug = compositionActiveForDebug; mCombiningStartTimeMs = startTimeMs; mCombiningGraceMs = graceMs; - // Always invalidate the whole view once so the global tint overlay appears or - // clears immediately — invalidateKey() in the animator only refreshes the space - // key's bounds, which isn't enough for the keyboard-wide tint. invalidate(); if (!mCombiningModeActive) return; if (mSpaceKey == null) return; @@ -584,11 +578,6 @@ public boolean hasGestureDebugPoints() { return mGestureDebugPointsDrawingPreview.hasSnapshot(); } - @Override - public void setGestureCommitPending(final boolean pending) { - mGestureFloatingTextDrawingPreview.setCommitPending(pending); - } - // Note that this method is called from a non-UI thread. @SuppressWarnings("static-method") public void setMainDictionaryAvailability(final boolean mainDictionaryAvailable) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index 7e979412a..d051891f9 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -142,38 +142,6 @@ private static TimerProxy getTimerProxy() { private boolean mIsDetectingGesture = false; // per PointerTracker. private static boolean sInGesture = false; - // ---- Combining-mode tap seeding ------------------------------------------------------ - // When the user taps a letter and within the combining-grace window starts a swipe, the - // pure concat path ("s" + recognizer-of-"ilo") produces unreliable results because the - // recognizer gets too few points to pin a word. So we SEED the next gesture's first - // pointer event with the prior tap's (x, y, time). The recognizer then sees a full - // s→i→l→o stroke and reliably produces "silo". - // - // To avoid double-counting the seed letter at commit time, InputLogic peeks at - // {@link #consumeGestureSeedCodepoint()} when the gesture commits; if the seed letter - // matches the first codepoint of the recognized word (case-insensitive), it strips it - // before the existing concat/replace logic runs. Net result: - // composing="s", seed='s', batch="silo" → strip → "ilo" → concat → "silo" - // composing="tech",seed='h', batch="hnology" → strip → "nology" → concat → "technology" - // composing="s", seed='s', batch="ilver" → no strip → concat → "silver" - // - // All static / globally-scoped: the prior tap can be on any tracker, and the gesture - // start can be on any tracker. UI thread only. - private static int sLastLetterTapX; - private static int sLastLetterTapY; - private static long sLastLetterTapTime; - private static int sLastLetterTapCodepoint; // 0 = none - private static int sCurrentGestureSeedCodepoint; // 0 = no seed for current gesture - - /** Called by InputLogic at the moment of consuming a gesture's batch result. Returns the - * codepoint that seeded the gesture (or 0), and clears the slot so the next gesture - * starts fresh. */ - public static int consumeGestureSeedCodepoint() { - final int seed = sCurrentGestureSeedCodepoint; - sCurrentGestureSeedCodepoint = 0; - return seed; - } - private static TypingTimeRecorder sTypingTimeRecorder; // The position and time at which first down event occurred. @@ -318,53 +286,13 @@ public static boolean isAnyInDraggingFinger() { public static void cancelAllPointerTrackers() { sPointerTrackerQueue.cancelAllPointerTrackers(); sInShortcutRowSwipe = false; - // Two-thumb typing (#1.2): drop any pending grace-period commit during teardown so - // the deferred runnable doesn't fire against a possibly-disposed view. If a commit - // was indeed pending, the only thing keeping {@code sInGesture} true was the grace - // window itself — clear it now so post-teardown touches don't see stale state. - if (BatchInputArbiter.cancelGrace()) { - sInGesture = false; - } - // Two-thumb typing (#1.2 visual): also clear the pending-commit indicator. - if (sDrawingProxy != null) { - sDrawingProxy.setGestureCommitPending(false); - } - } - - /** - * Static commit path used by the autospace grace period (#1.2). Called from - * {@link BatchInputArbiter} when a deferred commit fires (timer expired or - * {@link BatchInputArbiter#flushGrace} was invoked). Mirrors the body of the per-instance - * {@link #onEndBatchInput(InputPointers, long)} but skips the {@code mIsTrackingForActionDisabled} - * check because the original tracker may have been reused for a different finger by now — - * the per-instance flag is no longer meaningful for the previously-committed gesture. - * Always invoked on the main looper. - * - * @param keyboardSnapshot the {@link Keyboard} captured at scheduling time, used by the - * dual-thumb hinter (#2.1) for geometry; may be {@code null} (hinter no-ops). Using a - * captured snapshot rather than a live static avoids problems if the keyboard layout - * swapped during the grace window. - */ - private static void commitDeferredBatchInput( - final InputPointers aggregatedPointers, final long upEventTime, - final Keyboard keyboardSnapshot) { - sTypingTimeRecorder.onEndBatchInput(upEventTime); - sTimerProxy.cancelAllUpdateBatchInputTimers(); - final DualThumbHinter.Result result = - applyDualThumbHinting(aggregatedPointers, keyboardSnapshot); - pushGestureDebugSnapshot(aggregatedPointers, result.syntheticOnly); - sListener.onEndBatchInput(result.hinted); - sInGesture = false; - // Two-thumb typing (#1.2 visual): the pending-commit indicator is no longer relevant - // — the commit just fired. Clear unconditionally; it's a cheap no-op when not pending. - sDrawingProxy.setGestureCommitPending(false); } /** * Apply the dual-thumb point hinter (#2.1) to the aggregated pointers if the user has * enabled it. Returns an "identity" {@link DualThumbHinter.Result} (hinted == raw, * empty synthetic-only) when the pref is off OR no keyboard geometry is available — we - * don't have the key-width / midline needed by the hinter. + * don't have the key width needed by the hinter. */ private static DualThumbHinter.Result applyDualThumbHinting( final InputPointers raw, final Keyboard keyboard) { @@ -373,9 +301,7 @@ private static DualThumbHinter.Result applyDualThumbHinting( return DualThumbHinter.identity(raw); } final int keyWidth = keyboard.mMostCommonKeyWidth; - final int midlineX = (int)(keyboard.mOccupiedWidth - * (sv.mGestureDualThumbMidlinePct / 100f)); - return DualThumbHinter.postProcess(raw, keyWidth, midlineX); + return DualThumbHinter.postProcess(raw, keyWidth); } /** @@ -811,8 +737,6 @@ private void cancelBatchInput() { // Two-thumb typing (#2.1): drop any leftover debug overlay so it doesn't linger over // a cancelled gesture's input. sDrawingProxy.clearGestureDebugPoints(); - // Two-thumb typing (#1.2 visual): also clear the pending-commit indicator if it was on. - sDrawingProxy.setGestureCommitPending(false); } public void processMotionEvent(final MotionEvent me, final KeyDetector keyDetector) { @@ -880,40 +804,6 @@ private void onDownEvent(final int x, final int y, final long eventTime, sPointerTrackerQueue.releaseAllPointers(eventTime); } } - // Two-thumb typing (#1.2): if we're inside the autospace grace window of a previous - // gesture, this new pointer is either a continuation of that same composing word - // (letter on the alphabet keyboard, gesture handling still enabled) or a terminator - // (anything else). Decide BEFORE the rest of the down-event flow runs so the arbiter - // state is consistent by the time addDownEventPoint() below queries - // {@code sNextDownContinuesPendingGesture}. No-op when the user hasn't enabled the - // grace period — {@code isGracePending} is only ever true when - // {@code PREF_GESTURE_AUTOSPACE_GRACE_MS > 0}. - if (BatchInputArbiter.isGracePending()) { - // Gate on {@code shouldHandleGesture} too: if gesture typing was disabled (e.g. - // the user toggled the pref mid-grace, or we're on a layout where gestures aren't - // handled), {@link BatchInputArbiter#continuePendingGesture} would set a flag - // that never gets consumed by {@code addDownEventPoint} — leaking it into the - // next gesture. Flushing is the safe choice here. - final boolean isLetterContinuation = sGestureEnabler.shouldHandleGesture() - && key != null - && !key.isModifier() - && Character.isLetter(key.getCode()) - && mKeyboard != null && mKeyboard.mId.isAlphabetKeyboard(); - if (isLetterContinuation) { - // Drop the deferred commit and tell the arbiter to keep sGestureFirstDownTime - // intact, so this pointer's elapsed-time stamps line up with the existing - // aggregate. {@code sInGesture} stays true throughout. - BatchInputArbiter.continuePendingGesture(); - } else { - // Non-letter tap (space, punctuation, gestures off, …): commit the pending - // gesture word synchronously so this keystroke lands AFTER it. Clears - // {@code sInGesture} via the DeferredCommit callback before we proceed. - BatchInputArbiter.flushGrace(); - } - // Two-thumb typing (#1.2 visual): grace just resolved (continued or flushed) — the - // pending-commit indicator on the floating preview is no longer relevant. - sDrawingProxy.setGestureCommitPending(false); - } sPointerTrackerQueue.add(this); onDownEventInternal(x, y, eventTime); if (!sGestureEnabler.shouldHandleGesture()) { @@ -926,56 +816,10 @@ private void onDownEvent(final int x, final int y, final long eventTime, && key != null && !key.isModifier() && !mKeySwipeAllowed && !sInKeySwipe && !sInShortcutRowSwipe; if (mIsDetectingGesture) { - // Combining-mode tap seeding: if the user just tapped a letter within the - // (base + tap-extra) combining grace window, prepend that tap's position+time as - // the down-event for this gesture. The recognizer sees a continuous stroke from - // the tapped letter to the swiped letters and produces a multi-letter word - // reliably; without seeding "i→l→o" alone often doesn't recognize as "ilo". - // InputLogic.onUpdateTailBatchInputCompleted reads consumeGestureSeedCodepoint() - // and strips the leading seed letter from the recognized word before concat, so - // we don't double-count it. - // - // We tried also seeding from the composing-tail (for gesture-then-gesture: swipe - // "tech" + swipe "nology" → seed nology-swipe from h's key center). It made - // recognition WORSE: the synthetic h→n→o→l→o→g→y trajectory has the recognizer - // find a word starting with h that traces that shape, "colony" (h dropped), so - // we got "techcolony". Tap-seed has accurate real coords; tail-seed has only the - // key center, and the resulting stroke geometry is unrealistic. - int seedX = x; - int seedY = y; - long seedTime = eventTime; - sCurrentGestureSeedCodepoint = 0; - final SettingsValues sv = Settings.getValues(); - // Multi-part composition (#1.6): when the WordComposer extend-base path is - // active, it already feeds the lib the full prior-fragment trail with proper - // re-timed pointers. The single-point seed here would duplicate context and, - // worse, introduce a stale-time point at the merge boundary that breaks the - // recognizer's continuity assumptions (regressed 'silo' in earlier testing). - // Disable the PointerTracker seed entirely when multipart auto-extend is on. - // Shares one definition with InputLogic (covers grace-timer mode AND manual - // spacing) so the seed and the merged-trail path can never both fire. - final boolean multipartExtendActive = sv.isMultipartComposeActive(); - if (!multipartExtendActive - && sv.mCombiningGraceMs > 0 - && sLastLetterTapCodepoint > 0 - && key != null - && !key.isModifier() - && Character.isLetter(key.getCode()) - && mKeyboard != null && mKeyboard.mId.isAlphabetKeyboard() - && !sInGesture) { - final long timeSinceTap = eventTime - sLastLetterTapTime; - final long effectiveWindow = sv.mCombiningGraceMs + Math.max(0, sv.mCombiningTapExtraMs); - if (timeSinceTap >= 0 && timeSinceTap <= effectiveWindow) { - seedX = sLastLetterTapX; - seedY = sLastLetterTapY; - seedTime = sLastLetterTapTime; - sCurrentGestureSeedCodepoint = sLastLetterTapCodepoint; - } - } - mBatchInputArbiter.addDownEventPoint(seedX, seedY, seedTime, + mBatchInputArbiter.addDownEventPoint(x, y, eventTime, sTypingTimeRecorder.getLastLetterTypingTime(), getActivePointerTrackerCount()); mGestureStrokeDrawingPoints.onDownEvent( - seedX, seedY, mBatchInputArbiter.getElapsedTimeSinceFirstDown(seedTime)); + x, y, mBatchInputArbiter.getElapsedTimeSinceFirstDown(eventTime)); } } @@ -1572,37 +1416,11 @@ private void onUpEventInternal(final int x, final int y, final long eventTime) { if (currentKey != null) { callListenerOnRelease(currentKey, currentKey.getCode(), true); } - // The unified combining-mode timer lives in InputLogic now and handles the - // commit-vs-extend decision at end-of-gesture (it sees the result, not the raw - // pointer events). So we always end the gesture immediately here: graceMs = 0. - // The old BatchInputArbiter grace path stays in place for backwards-compat with - // PREF_GESTURE_AUTOSPACE_GRACE_MS, but it's now dormant by default. - final int graceMs = 0; - // Two-thumb typing (#2.1): capture the current keyboard so the deferred commit - // path can apply the dual-thumb hinter with the geometry that was live at the - // moment of lift. - final Keyboard keyboardSnapshotForCommit = mKeyboard; + // InputLogic owns combining after the completed gesture has been recognized. if (mBatchInputArbiter.mayEndBatchInput( - eventTime, getActivePointerTrackerCount(), graceMs, this, - (pts, ts) -> commitDeferredBatchInput(pts, ts, keyboardSnapshotForCommit))) { + eventTime, getActivePointerTrackerCount(), this)) { sInGesture = false; } - // Multi-part word composition: record the gesture's lift position as a "letter - // tap" so the NEXT gesture's onDownEvent seeding code (sLastLetterTap*) treats - // it as a continuation point. Without this, swipe+swipe gives un-seeded second - // strokes that the recognizer interprets as standalone words (e.g. tech+nology - // -> "techbiology"). Only record if the lift was on a real letter key. - if (Settings.getValues().mMultipartTapSeedGesture - && Settings.getValues().mCombiningGraceMs > 0 - && currentKey != null) { - final int code = currentKey.getCode(); - if (code > 0 && Character.isLetter(code)) { - sLastLetterTapX = mKeyX; - sLastLetterTapY = mKeyY; - sLastLetterTapTime = eventTime; - sLastLetterTapCodepoint = code; - } - } showGestureTrail(); return; } @@ -1615,17 +1433,9 @@ eventTime, getActivePointerTrackerCount(), graceMs, this, return; } detectAndSendKey(currentKey, mKeyX, mKeyY, eventTime); - // Combining-mode seeding: remember the last letter tap so a follow-up gesture can - // seed its first pointer event with this position and time. Only letter taps qualify - // (modifiers / numbers / symbols shouldn't seed). The actual seeding decision lives - // on the next onDownEvent path, gated on combining grace > 0 and time-since-tap. if (currentKey != null) { final int code = currentKey.getCode(); if (code > 0 && Character.isLetter(code)) { - sLastLetterTapX = mKeyX; - sLastLetterTapY = mKeyY; - sLastLetterTapTime = eventTime; - sLastLetterTapCodepoint = code; pushTapDebugPoint(mKeyX, mKeyY, mPointerId, eventTime); } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index 5ae3b7ebd..9d2dd9fb8 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -80,6 +80,8 @@ public interface TouchpadListener { public void run() { if (mIsTwoFingerTap) { mIsTwoFingerLongPress = true; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); if (mListener != null) { mListener.onThreeFingerSwipeLeft(); } @@ -301,6 +303,12 @@ private void setupTouchSurface() { removeCallbacks(mTwoFingerTapRunnable); postDelayed(mTwoFingerLongPressRunnable, 400); + } else if (pointerCount > 2) { + mIsTwoFingerTap = false; + mIsTwoFingerScroll = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + removeCallbacks(mTwoFingerLongPressRunnable); } return true; @@ -315,6 +323,8 @@ private void setupTouchSurface() { if (Math.abs(midX - mTwoFingerStartX) > 5f * density || Math.abs(midY - mTwoFingerStartY) > 5f * density) { mIsTwoFingerTap = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); removeCallbacks(mTwoFingerLongPressRunnable); } @@ -412,8 +422,7 @@ private void setupTouchSurface() { mIsTwoFingerTap = false; removeCallbacks(mTwoFingerLongPressRunnable); mIsTwoFingerLongPress = false; - mTwoFingerTapCount = 0; - removeCallbacks(mTwoFingerTapRunnable); + // POINTER_UP already scheduled the completed two-finger tap. if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -450,6 +459,9 @@ private void setupTouchSurface() { mTwoFingerTapCount++; removeCallbacks(mTwoFingerTapRunnable); postDelayed(mTwoFingerTapRunnable, 250); + } else { + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); } mIsTwoFingerScroll = false; mIsTwoFingerTap = false; diff --git a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardClipEditActivity.kt b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardClipEditActivity.kt index e45828cfc..abea97066 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardClipEditActivity.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardClipEditActivity.kt @@ -109,6 +109,7 @@ class ClipboardClipEditActivity : Activity() { private fun buildEditor(text: String): EditText { return EditText(this).apply { + id = R.id.clipboard_clip_editor layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(280) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java b/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java index edcdeb28a..3020a60a9 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/BatchInputArbiter.java @@ -6,9 +6,6 @@ package helium314.keyboard.keyboard.internal; -import android.os.Handler; -import android.os.Looper; - import helium314.keyboard.latin.common.Constants; import helium314.keyboard.latin.common.InputPointers; @@ -42,45 +39,6 @@ void onUpdateBatchInput( // like the other statics here. private static final PointerIdNormalizer sPointerIdNormalizer = new PointerIdNormalizer(); - // ---- Two-thumb typing: autospace grace period (#1.2) ---- - // When the last finger of a gesture lifts and the user has configured a non-zero grace - // window, we delay the actual commit (the "autospace grace period"). If another finger - // comes down on a letter during the window, the gesture continues into the same composing - // word (see {@link #continuePendingGesture}). If the user instead taps a non-letter - // (e.g. space, punctuation), the commit is flushed synchronously so the next keystroke - // lands AFTER the gesture word (see {@link #flushGrace}). If neither happens within the - // window the deferred commit fires on its own. - // - // The grace deadline is measured "from last finger lift to next finger DOWN", not "from - // last finger lift to next gesture END". Once a follow-up touch-down happens within the - // window, {@link #continuePendingGesture} removes the Handler callback outright; the new - // gesture can then take as long as it needs without re-triggering a commit. When THAT - // gesture's last finger lifts, a fresh grace timer is scheduled. So typing "tech" + a - // 2-second-long "nology" swipe works as long as the "n" down happens within graceMs of - // the "h" lift, regardless of how long "nology" itself takes. - // - // All access is on the keyboard view's UI thread (touch events + Handler posts to main - // looper), so the static state needs no extra synchronization beyond the existing - // {@code synchronized (sAggregatedPointers)} blocks. - private static Handler sGraceHandler; - private static Runnable sPendingGraceRunnable; - // One-shot flag set by {@link #continuePendingGesture} and consumed by the very next - // {@link #addDownEventPoint}. Tells the arbiter that the down event belongs to a - // gesture that was already in progress (just emerging from a grace window), so we must - // NOT reset {@link #sGestureFirstDownTime} — keeping elapsed-time stamps consistent with - // the still-living {@link #sAggregatedPointers}. - private static boolean sNextDownContinuesPendingGesture; - - /** - * Functional interface (SAM) for the deferred-commit path of {@link #mayEndBatchInput}. - * Implementations are expected to use only static collaborators of {@link PointerTracker} - * (not per-instance state) because by the time a grace timer fires the originating - * {@code PointerTracker} instance may already have been reused for a different finger. - */ - public interface DeferredCommit { - void commit(InputPointers aggregatedPointers, long upEventTime); - } - private final GestureStrokeRecognitionPoints mRecognitionPoints; private final int mPointerId; @@ -112,14 +70,7 @@ public int getElapsedTimeSinceFirstDown(final long eventTime) { */ public void addDownEventPoint(final int x, final int y, final long downEventTime, final long lastLetterTypingTime, final int activePointerCount) { - // Two-thumb typing (#1.2): if the previous gesture was waiting on a grace window and - // {@link #continuePendingGesture} just declared this down to be a continuation, leave - // {@link #sGestureFirstDownTime} alone so the new pointer's elapsed-time stamps stay - // on the SAME scale as the existing aggregated pointers. We consume the one-shot flag - // here regardless of whether the {@code activePointerCount == 1} branch fires. - final boolean continuingPrior = sNextDownContinuesPendingGesture; - sNextDownContinuesPendingGesture = false; - if (activePointerCount == 1 && !continuingPrior) { + if (activePointerCount == 1) { sGestureFirstDownTime = downEventTime; } final int elapsedTimeSinceFirstDown = getElapsedTimeSinceFirstDown(downEventTime); @@ -212,33 +163,13 @@ public void updateBatchInput(final long moveEventTime, /** * Determine whether the batch input has ended successfully or continues. * - *

    When the last finger lifts and {@code graceMs > 0}, the actual commit is deferred by - * that many milliseconds (the "autospace grace period", two-thumb typing feature #1.2). - * During the grace window a follow-up pointer can call {@link #continuePendingGesture} to - * keep typing the same word, or {@link #flushGrace} to commit immediately so the next - * keystroke lands after it. If nothing happens within the window, {@code deferredCommit} - * fires on the main looper. - * - *

    The deferred path takes a {@link DeferredCommit} rather than going through the - * {@code listener} because by the time a grace timer fires the originating - * {@link PointerTracker} instance may already have been reused for a different finger — - * the deferred commit must rely on static state only (see {@link DeferredCommit}). - * * @param upEventTime the time of this up event. * @param activePointerCount the number of active pointers when this pointer up event occurs. - * @param graceMs zero for today's immediate-commit behaviour; otherwise the grace window in ms. - * @param listener gesture listener; receives {@code onEndBatchInput} immediately when - * {@code graceMs <= 0}. Not used by the deferred path. - * @param deferredCommit invoked when a deferred commit fires (timer expired or grace was - * flushed). Ignored when {@code graceMs <= 0}. May be {@code null} only if the caller - * guarantees {@code graceMs <= 0}. - * @return {@code true} only when this call committed the batch synchronously. {@code false} - * means either more fingers are still down OR a grace timer was scheduled — in both - * cases the gesture is logically still in progress. + * @param listener receives the completed batch when the final pointer lifts. + * @return whether this call completed the batch. */ public boolean mayEndBatchInput(final long upEventTime, final int activePointerCount, - final int graceMs, final BatchInputArbiterListener listener, - final DeferredCommit deferredCommit) { + final BatchInputArbiterListener listener) { synchronized (sAggregatedPointers) { mRecognitionPoints.appendAllBatchPoints(sAggregatedPointers, sPointerIdNormalizer.slotFor(mPointerId)); @@ -246,113 +177,8 @@ public boolean mayEndBatchInput(final long upEventTime, final int activePointerC // Other fingers are still down — gesture continues, no commit yet. return false; } - if (graceMs <= 0) { - // Immediate-commit path — exact original behaviour. - listener.onEndBatchInput(sAggregatedPointers, upEventTime); - return true; - } - scheduleGraceFinish(upEventTime, graceMs, deferredCommit); - return false; - } - } - - /** - * Backwards-compatible overload used by call sites that don't opt into the autospace - * grace period; behaves identically to the original method. - */ - public boolean mayEndBatchInput(final long upEventTime, final int activePointerCount, - final BatchInputArbiterListener listener) { - return mayEndBatchInput(upEventTime, activePointerCount, 0, listener, null); - } - - // ---- Grace-period helpers (two-thumb typing #1.2) ---- - - /** @return {@code true} if a deferred batch-end commit is currently pending. */ - public static boolean isGracePending() { - return sPendingGraceRunnable != null; - } - - /** - * Cancel any pending grace-period commit without committing. Intended for cleanup paths - * (gesture cancellation, view teardown, …). After this call the next down on a fresh - * gesture resets {@link #sGestureFirstDownTime} like today. - * - * @return {@code true} if a commit was pending and was canceled. - */ - public static boolean cancelGrace() { - if (sPendingGraceRunnable == null) { - // Defensive: a stale continuation flag from an aborted continuation path could - // still be set even if no runnable is pending; clear it so a brand-new gesture - // starts cleanly. - sNextDownContinuesPendingGesture = false; - return false; + listener.onEndBatchInput(sAggregatedPointers, upEventTime); + return true; } - sGraceHandler.removeCallbacks(sPendingGraceRunnable); - sPendingGraceRunnable = null; - sNextDownContinuesPendingGesture = false; - return true; - } - - /** - * Cancel a pending grace-period commit AND mark the next pointer-down as a continuation of - * the gesture word that was waiting to commit. Use this when a follow-up finger lands on a - * letter during the grace window — the deferred commit is dropped and the new pointer's - * events flow into the existing {@link #sAggregatedPointers} as if no lift had happened. - * - * @return {@code true} if a commit was pending and was canceled. If {@code false}, the - * continuation flag is NOT set (no commit to continue from). - */ - public static boolean continuePendingGesture() { - if (sPendingGraceRunnable == null) return false; - sGraceHandler.removeCallbacks(sPendingGraceRunnable); - sPendingGraceRunnable = null; - sNextDownContinuesPendingGesture = true; - return true; - } - - /** - * Synchronously fire a pending grace-period commit. Used when a follow-up pointer goes - * down on a non-letter key (space, punctuation, …) — we need to commit the gesture word - * NOW so the keystroke that follows lands after it in the input field. No-op if no - * commit was pending. - */ - public static void flushGrace() { - final Runnable pending = sPendingGraceRunnable; - if (pending == null) return; - sGraceHandler.removeCallbacks(pending); - sPendingGraceRunnable = null; - sNextDownContinuesPendingGesture = false; - pending.run(); - } - - private static void scheduleGraceFinish(final long upEventTime, final int graceMs, - final DeferredCommit deferredCommit) { - // Defensive: cancel any previously-scheduled commit (shouldn't normally happen, but - // re-entrant up-events from {@code onPhantomUpEvent} have surprised people before). - cancelGrace(); - if (sGraceHandler == null) { - sGraceHandler = new Handler(Looper.getMainLooper()); - } - // Each scheduled runnable carries its own one-shot guard so we never double-commit - // even if {@link #flushGrace} synchronously runs it while a Handler dispatch is in - // flight (in practice {@code removeCallbacks} cleans the queue, but the explicit - // guard makes the invariant easy to reason about). - final Runnable runnable = new Runnable() { - private boolean mConsumed; - @Override - public void run() { - if (mConsumed) return; - mConsumed = true; - // Null the slot BEFORE invoking the commit so any re-entrant gesture activity - // inside {@link DeferredCommit#commit} sees a clean "no pending grace" state. - if (sPendingGraceRunnable == this) sPendingGraceRunnable = null; - // A timer-fired commit is never a continuation — make sure the flag isn't - // stale from a prior aborted continuation attempt. - sNextDownContinuesPendingGesture = false; - deferredCommit.commit(sAggregatedPointers, upEventTime); - } - }; - sPendingGraceRunnable = runnable; - sGraceHandler.postDelayed(runnable, graceMs); } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java index 731d4df51..50be04f6f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java @@ -93,14 +93,4 @@ void setGestureDebugPoints(@NonNull helium314.keyboard.latin.common.InputPointer /** True when there are debug points currently visible on the overlay. */ boolean hasGestureDebugPoints(); - /** - * Toggle a "commit pending" visual indicator on the gesture floating preview text - * (feature #1.2). Shown during the autospace grace window so the user has a visible cue - * that a commit is imminent — addressing the perceived sluggishness when the grace timer - * delays the commit. Implementations typically append an ellipsis to the displayed word. - * - * @param pending {@code true} when a grace-deferred commit is queued; {@code false} on - * normal commit / cancel / continuation. - */ - void setGestureCommitPending(boolean pending); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/DualThumbHinter.java b/app/src/main/java/helium314/keyboard/keyboard/internal/DualThumbHinter.java index eeba25dd2..9e4b86d81 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DualThumbHinter.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DualThumbHinter.java @@ -24,10 +24,8 @@ * counteract that by injecting synthetic on-stroke waypoints at each tap's centroid so * the recognizer reads the tap as a deliberate detour on the glide path. *

  • Stray opposite-hand tap (e.g. "giraffe" with a left-thumb {@code i}): the tap - * looks geometrically out of place and skews the recognizer toward the wrong word. The - * midline-based dampener (TODO; not in the MVP) would reduce that tap's influence by - * padding its on-path neighbours instead of removing the tap outright (removing risks - * losing keys that ONLY that hand types).
  • + * looks geometrically out of place and can skew the recognizer toward the wrong word. + * The proximity guard leaves distant taps unchanged rather than amplifying them. * * *

    The whole hinter is gated by {@code PREF_GESTURE_DUAL_THUMB_HINTING} (default off, marked @@ -133,14 +131,11 @@ public static Result identity(@androidx.annotation.NonNull final InputPointers r * @param keyWidthPx most common key width in pixels (used as the spatial radius for the * "tap stays on one key" classifier). Pass {@code Math.max(keyWidthPx, 1)} to avoid * divide-by-zero if the keyboard hasn't been laid out yet. - * @param midlineXPx horizontal pixel offset of the left/right hand split. Currently - * informational — reserved for the (future) stray-tap dampener. * @return a {@link Result} carrying the hinted aggregate AND just the synthetic * injections separately. The hinted aggregate may equal {@code input} if no * injections were made. */ - public static Result postProcess(final InputPointers input, final int keyWidthPx, - @SuppressWarnings("unused") final int midlineXPx) { + public static Result postProcess(final InputPointers input, final int keyWidthPx) { final int n = input.getPointerSize(); if (n < 2) return new Result(input, new InputPointers(0)); final int[] xs = input.getXCoordinates(); @@ -188,13 +183,6 @@ public static Result postProcess(final InputPointers input, final int keyWidthPx } } - // TODO(#2.1 follow-up): stray-tap dampener. For each TAP that's geometrically a detour - // off its overlapping STROKE AND on the opposite side of midlineXPx from the STROKE's - // dominant side, append duplicates of the stroke's neighbouring on-path points instead - // (do NOT delete the tap — letters that only that hand types must survive). The - // proximity guard above only PREVENTS regressing the "giraffe" case; the dampener - // would actively IMPROVE it. - if (injections.isEmpty()) return new Result(input, new InputPointers(0)); // Pre-sort injections by time so we can two-pointer merge into the chronological diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java index d1cf504fc..43b0c0cec 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureDebugPointsDrawingPreview.java @@ -22,7 +22,7 @@ * point-shaping experiments — the user can toggle {@code PREF_GESTURE_DEBUG_DRAW_POINTS} on, * gesture a word, and visually inspect raw vs. processed samples. * - *

    The overlay snapshots the inputs at each batch-end (immediate or grace-deferred) and keeps + *

    The overlay snapshots the inputs at each batch-end and keeps * them visible until the next batch starts, so the trail is still on screen when the user * compares it with the suggestion strip. The overlay distinguishes the streams and gesture * structure: diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureFloatingTextDrawingPreview.java b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureFloatingTextDrawingPreview.java index e96db7c1f..19eb3c10d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/GestureFloatingTextDrawingPreview.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/GestureFloatingTextDrawingPreview.java @@ -88,11 +88,6 @@ public Paint getBackgroundPaint() { private int mPreviewTextX; private int mPreviewTextY; private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); - // Two-thumb typing (#1.2): when the autospace grace period defers the commit, append "…" - // to the displayed word as a visual cue that a commit is imminent but the user can still - // continue typing to extend the same word. Toggled via DrawingProxy.setGestureCommitPending - // from PointerTracker right after the grace timer is scheduled / cleared. - private boolean mIsCommitPending; private final int[] mLastPointerCoords = CoordinateUtils.newInstance(); public GestureFloatingTextDrawingPreview(final TypedArray mainKeyboardViewAttr) { @@ -116,25 +111,9 @@ public void setSuggestedWords(@NonNull final SuggestedWords suggestedWords) { updatePreviewPosition(); } - /** - * Set whether the displayed word should be tagged as "commit pending" (with a trailing "…"). - * No-op when the preview isn't enabled or the visual state is unchanged. - */ - public void setCommitPending(final boolean pending) { - if (mIsCommitPending == pending) return; - mIsCommitPending = pending; - if (!isPreviewEnabled()) return; - // Geometry depends on the displayed text width — recompute so the rounded background - // tracks the new (possibly longer) text. - updatePreviewPosition(); - } - - /** Returns the word the preview is currently displaying, with the pending-commit suffix when applicable. */ private String getDisplayedWord() { if (mSuggestedWords.isEmpty()) return ""; - final String word = mSuggestedWords.getWord(0); - if (TextUtils.isEmpty(word)) return ""; - return mIsCommitPending ? word + "\u2026" : word; + return mSuggestedWords.getWord(0); } @Override diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index b66fa071d..c6607ae27 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -189,6 +189,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread ToolbarKey.TRANSLATE -> R.drawable.ic_translate + ToolbarKey.OCR -> R.drawable.ic_ocr ToolbarKey.CUSTOM_AI_1 -> R.drawable.ic_custom_ai_1 ToolbarKey.CUSTOM_AI_2 -> R.drawable.ic_custom_ai_2 ToolbarKey.CUSTOM_AI_3 -> R.drawable.ic_custom_ai_3 @@ -274,6 +275,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread ToolbarKey.TRANSLATE -> R.drawable.ic_translate + ToolbarKey.OCR -> R.drawable.ic_ocr ToolbarKey.CUSTOM_AI_1 -> R.drawable.ic_custom_ai_1 ToolbarKey.CUSTOM_AI_2 -> R.drawable.ic_custom_ai_2 ToolbarKey.CUSTOM_AI_3 -> R.drawable.ic_custom_ai_3 @@ -359,6 +361,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread_rounded ToolbarKey.TRANSLATE -> R.drawable.ic_translate_rounded + ToolbarKey.OCR -> R.drawable.ic_ocr ToolbarKey.CUSTOM_AI_1 -> R.drawable.ic_custom_ai_1 ToolbarKey.CUSTOM_AI_2 -> R.drawable.ic_custom_ai_2 ToolbarKey.CUSTOM_AI_3 -> R.drawable.ic_custom_ai_3 diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index a6081d486..0512712e3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -210,6 +210,7 @@ object KeyCode { const val HANDWRITING = -10074 const val CLEAR_HANDWRITING = -10075 const val SWITCH_TO_USER_IME = -10076 + const val OCR = -10077 // Intents @@ -236,7 +237,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING, OCR, CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME -> this diff --git a/app/src/main/java/helium314/keyboard/latin/AudioAndHapticFeedbackManager.java b/app/src/main/java/helium314/keyboard/latin/AudioAndHapticFeedbackManager.java index aeb47b8f2..ece936bbd 100644 --- a/app/src/main/java/helium314/keyboard/latin/AudioAndHapticFeedbackManager.java +++ b/app/src/main/java/helium314/keyboard/latin/AudioAndHapticFeedbackManager.java @@ -100,13 +100,23 @@ public void vibrate(final long milliseconds, final int amplitudePercent) { } private boolean reevaluateIfSoundIsOn() { - if (mSettingsValues == null || !mSettingsValues.mSoundOn || mAudioManager == null || mDoNotDisturb) { + if (mSettingsValues == null || !mSettingsValues.mSoundOn || mAudioManager == null) { return false; } - return mAudioManager.getRingerMode() == AudioManager.RINGER_MODE_NORMAL; + if (mSettingsValues.mSoundMuteInDnd && mDoNotDisturb) { + return false; + } + if (mSettingsValues.mSoundMuteInSilent && mAudioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL) { + return false; + } + return true; } public void performAudioFeedback(final int code, final HapticEvent hapticEvent) { + performAudioFeedback(code, hapticEvent, 0.5f); + } + + public void performAudioFeedback(final int code, final HapticEvent hapticEvent, final float keyXRatio) { if (!mSoundOn) { return; } @@ -115,7 +125,7 @@ public void performAudioFeedback(final int code, final HapticEvent hapticEvent) } final float volume = mSettingsValues != null ? mSettingsValues.mKeypressSoundVolume : -0.01f; if (mContext != null) { - final boolean played = CustomSoundManager.Companion.getInstance(mContext).playSound(code, volume); + final boolean played = CustomSoundManager.Companion.getInstance(mContext).playSound(code, volume, keyXRatio); if (played) { return; } @@ -166,4 +176,23 @@ public void onRingerModeChanged(boolean doNotDisturb) { mDoNotDisturb = doNotDisturb; mSoundOn = reevaluateIfSoundIsOn(); } + + public void onStartInputView() { + if (mContext != null && mSoundOn) { + CustomSoundManager.Companion.getInstance(mContext).onStartInputView(); + } + } + + public void onFinishInputView() { + if (mContext != null) { + CustomSoundManager.Companion.getInstance(mContext).onFinishInputView(); + } + } + + public void onDestroy() { + if (mContext != null) { + CustomSoundManager.Companion.getInstance(mContext).onDestroy(); + } + } } + diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index ff87bdad2..66d253495 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -4,6 +4,7 @@ package helium314.keyboard.latin import android.content.ClipboardManager import android.content.Context +import android.graphics.Bitmap import android.os.Build import android.text.InputType import android.text.TextUtils @@ -19,18 +20,27 @@ import helium314.keyboard.latin.common.ColorType import helium314.keyboard.latin.common.isValidNumber import helium314.keyboard.latin.database.ClipboardDao import helium314.keyboard.latin.databinding.ClipboardSuggestionBinding +import helium314.keyboard.latin.databinding.ScreenshotSuggestionBinding +import helium314.keyboard.latin.ocr.OcrPluginLoader +import helium314.keyboard.latin.ocr.OcrPipeline +import helium314.keyboard.latin.ocr.ScreenshotHelper import helium314.keyboard.latin.utils.InputTypeUtils import helium314.keyboard.latin.utils.ToolbarKey import android.database.ContentObserver import android.net.Uri import android.os.Handler import android.os.Looper +import android.widget.Toast import kotlin.concurrent.thread import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.prefs +import java.util.concurrent.Executor -class ClipboardHistoryManager( - private val latinIME: LatinIME +class ClipboardHistoryManager @JvmOverloads constructor( + private val latinIME: LatinIME, + private val screenshotExecutor: Executor = ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD), + private val loadScreenshot: (Uri) -> Bitmap? = { ScreenshotHelper.loadScaledBitmap(latinIME, it) }, + private val createOcrPipeline: () -> OcrPipeline = { OcrPipeline(latinIME) } ) : ClipboardManager.OnPrimaryClipChangedListener { private lateinit var clipboardManager: ClipboardManager @@ -39,6 +49,8 @@ class ClipboardHistoryManager( // to the main Looper is fine for the whole process. This avoids // allocating a fresh Handler on every postDelayed(). private val mainHandler = Handler(Looper.getMainLooper()) + // Bitmap deliveries must still run their stale-request cleanup after ordinary UI posts are removed. + private val ocrHandler = Handler(Looper.getMainLooper()) private var clipboardSuggestionView: View? = null private var _clipboardDao: ClipboardDao? = null private var clipboardDao: ClipboardDao? @@ -68,6 +80,12 @@ class ClipboardHistoryManager( private var cachedScreenshotInfo: ScreenshotInfo? = null private var screenshotObserver: ContentObserver? = null + private var inputGeneration = 0L + private var ocrGeneration = 0L + private var screenshotPipeline: OcrPipeline? = null + + private fun isKeyboardVisible(): Boolean = latinIME.isInputViewShown || + latinIME.floatingKeyboardManager?.let { it.isFloating && it.overlayRoot?.isShown == true } == true private fun registerScreenshotObserver() { if (screenshotObserver != null) return @@ -81,6 +99,7 @@ class ClipboardHistoryManager( screenshotObserver = object : ContentObserver(mainHandler) { override fun onChange(selfChange: Boolean, uri: Uri?) { super.onChange(selfChange, uri) + if (!isKeyboardVisible()) return if (latinIME.mSettings.current.mSuggestScreenshots) { updateLatestScreenshotCache { latinIME.tryShowClipboardSuggestion() @@ -110,6 +129,7 @@ class ClipboardHistoryManager( } private fun updateLatestScreenshotCache(onComplete: (() -> Unit)? = null) { + val session = inputGeneration val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { android.Manifest.permission.READ_MEDIA_IMAGES } else { @@ -117,11 +137,11 @@ class ClipboardHistoryManager( } if (latinIME.checkCallingOrSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED) { cachedScreenshotInfo = null - onComplete?.invoke() + if (session == inputGeneration && isKeyboardVisible()) onComplete?.invoke() return } - ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + screenshotExecutor.execute { val projection = mutableListOf( android.provider.MediaStore.Images.Media._ID, android.provider.MediaStore.Images.Media.DISPLAY_NAME, @@ -199,8 +219,9 @@ class ClipboardHistoryManager( } mainHandler.post { - onComplete?.invoke() - latinIME.tryShowClipboardSuggestion() + if (session == inputGeneration && isKeyboardVisible()) { + if (onComplete != null) onComplete() else latinIME.tryShowClipboardSuggestion() + } } return@execute } @@ -214,7 +235,7 @@ class ClipboardHistoryManager( } cachedScreenshotInfo = null mainHandler.post { - onComplete?.invoke() + if (session == inputGeneration && isKeyboardVisible()) onComplete?.invoke() } } } @@ -256,6 +277,7 @@ class ClipboardHistoryManager( } fun onStartInputView() { + onStartInput() val prefs = latinIME.prefs() val lastDismissed = prefs.getString("last_dismissed_screenshot_uri", "") if (cachedScreenshotInfo != null && cachedScreenshotInfo?.uri?.toString() != lastDismissed) { @@ -267,9 +289,26 @@ class ClipboardHistoryManager( } fun onFinishInputView() { + onFinishInput() mainHandler.removeCallbacksAndMessages(null) } + fun onStartInput() { + inputGeneration++ + cancelScreenshotOcr() + } + + fun onFinishInput() { + inputGeneration++ + cancelScreenshotOcr() + } + + fun cancelScreenshotOcr() { + ocrGeneration++ + screenshotPipeline?.release() + screenshotPipeline = null + } + private fun cleanUpImageCache() { try { val cacheDir = java.io.File(latinIME.cacheDir, "clipboard_images") @@ -288,6 +327,7 @@ class ClipboardHistoryManager( } fun onDestroy() { + onFinishInput() unregisterScreenshotObserver() clipboardManager.removePrimaryClipChangedListener(this) mainHandler.removeCallbacksAndMessages(null) @@ -633,36 +673,47 @@ class ClipboardHistoryManager( lastSuggestedScreenshotUri = contentUri.toString() } - val binding = ClipboardSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) - val textView = binding.clipboardSuggestionText - textView.text = "Screenshot" - + val ocrEnabled = OcrPluginLoader.hasPlugin(latinIME) && latinIME.prefs().getBoolean(OcrPluginLoader.PREF_OCR_SUGGEST_SCREENSHOT_TEXT, true) + val binding = ScreenshotSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) + val pasteButton = binding.screenshotPasteButton + val thumbnailImage = binding.screenshotThumbnailImage + val extractButton = binding.screenshotExtractTextButton + val closeButton = binding.screenshotSuggestionClose + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { try { - val thumb = latinIME.contentResolver.loadThumbnail(contentUri, android.util.Size(120, 120), null) - - val size = Math.min(thumb.width, thumb.height) - val x = (thumb.width - size) / 2 - val y = (thumb.height - size) / 2 - val croppedThumb = android.graphics.Bitmap.createBitmap(thumb, x, y, size, size) - - val drawable = android.graphics.drawable.BitmapDrawable(latinIME.resources, croppedThumb) - textView.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + val thumb = latinIME.contentResolver.loadThumbnail(contentUri, android.util.Size(160, 160), null) + thumbnailImage.setImageBitmap(thumb) } catch (e: Exception) { val clipIcon = latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.PASTE.name.lowercase()) - textView.setCompoundDrawablesRelativeWithIntrinsicBounds(clipIcon, null, null, null) + thumbnailImage.setImageDrawable(clipIcon) } } - textView.setOnClickListener { + if (ocrEnabled) { + extractButton.visibility = View.VISIBLE + extractButton.setImageResource(R.drawable.ic_ocr_extract) + + extractButton.setOnClickListener { + dontShowCurrentSuggestion = true + lastSuggestedScreenshotUri = contentUri.toString() + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + binding.root.isGone = true + + extractScreenshot(contentUri) + } + } else { + extractButton.visibility = View.GONE + } + + pasteButton.setOnClickListener { dontShowCurrentSuggestion = true lastSuggestedScreenshotUri = contentUri.toString() latinIME.onImageSelected(contentUri.toString()) AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) binding.root.isGone = true } - - val closeButton = binding.clipboardSuggestionClose + closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) closeButton.setOnClickListener { val prefs = latinIME.prefs() @@ -678,13 +729,53 @@ class ClipboardHistoryManager( } val colors = latinIME.mSettings.current.mColors - textView.setTextColor(colors.get(ColorType.KEY_TEXT)) - colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND) - + colors.setColor(extractButton, ColorType.REMOVE_SUGGESTION_ICON) + colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) + return binding.root } + internal fun extractScreenshot(contentUri: Uri) { + cancelScreenshotOcr() + val request = ocrGeneration + val session = inputGeneration + val inputSession = latinIME.inputSessionGeneration + val editor = latinIME.currentInputEditorInfo + val isCurrent = { + request == ocrGeneration && session == inputGeneration && + inputSession == latinIME.inputSessionGeneration && + editor === latinIME.currentInputEditorInfo && latinIME.currentInputStarted && + isKeyboardVisible() + } + if (!isCurrent()) return + screenshotExecutor.execute { + val bitmap = loadScreenshot(contentUri) + ocrHandler.post { + if (!isCurrent()) { + bitmap?.recycle() + return@post + } + if (bitmap != null) { + val pipeline = createOcrPipeline() + screenshotPipeline = pipeline + pipeline.processImage( + bitmap = bitmap, + onSuccess = { lines -> latinIME.mKeyboardSwitcher.showOcrResult(lines) }, + onError = { err -> Toast.makeText(latinIME, err, Toast.LENGTH_SHORT).show() }, + isRequestCurrent = isCurrent, + onInsertText = { text -> + latinIME.onTextInput(text) + latinIME.mKeyboardSwitcher.hideOcrPanels() + } + ) + } else { + Toast.makeText(latinIME, R.string.ocr_screenshot_load_failed, Toast.LENGTH_SHORT).show() + } + } + } + } + private fun removeClipboardSuggestion() { dontShowCurrentSuggestion = true val csv = clipboardSuggestionView ?: return diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index 570ea3e48..d57c13a47 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -119,6 +119,11 @@ void resetDictionaries( boolean isBlacklisted(String word); + /** Monotonic revision of dictionary content visible to suggestion consumers. */ + default long getDictionaryRevision() { + return 0L; + } + void closeDictionaries(); /** main dictionaries are loaded asynchronously after resetDictionaries */ diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index d5a732bab..f568055fe 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -52,6 +52,7 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong /** * Facilitates interaction with different kinds of dictionaries. Provides APIs @@ -68,8 +69,46 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var mLoadedDownloadPrefs: Map = emptyMap() + @Volatile private var dictionaryGroups = listOf(DictionaryGroup()) + private val mDictionaryRevision = AtomicLong() + private val spellingRevision = AtomicLong() + private val dictionaryChangeListener = ExpandableBinaryDictionary.DictionaryChangeListener { affectsIndex -> + if (affectsIndex) onDictionaryChanged() else invalidateSpellingCaches() + } + + override fun getDictionaryRevision(): Long = mDictionaryRevision.get() + + private fun onDictionaryChanged() { + mDictionaryRevision.incrementAndGet() + invalidateSpellingCaches() + } + + private fun invalidateSpellingCaches() { + spellingRevision.incrementAndGet() + mValidSpellingWordReadCache?.evictAll() + mValidSpellingWordWriteCache?.evictAll() + } + + private fun observeDictionaryChanges(groups: List) { + for (group in groups) { + group.onDictionaryChanged = ::onDictionaryChanged + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.forEach { + group.getSubDict(it)?.addDictionaryChangeListener(dictionaryChangeListener) + } + } + } + + private fun stopObservingDictionaryChanges(groups: List) { + for (group in groups) { + group.onDictionaryChanged = {} + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.forEach { + group.getSubDict(it)?.removeDictionaryChangeListener(dictionaryChangeListener) + } + } + } + private val initializedMainDictionary = java.util.concurrent.atomic.AtomicBoolean(false) private val pendingMainDictionaryLoad = java.util.concurrent.atomic.AtomicBoolean(false) @@ -219,7 +258,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { val oldDictionaryGroups: List synchronized(this) { oldDictionaryGroups = dictionaryGroups + stopObservingDictionaryChanges(oldDictionaryGroups) dictionaryGroups = newDictionaryGroups + observeDictionaryChanges(newDictionaryGroups) + onDictionaryChanged() refreshMainDictionaryReadinessState() if (hasAtLeastOneUninitializedMainDictionary()) { asyncReloadUninitializedMainDictionaries(context, locales, listener) @@ -237,9 +279,6 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } } - - mValidSpellingWordWriteCache?.evictAll() - mValidSpellingWordReadCache?.evictAll() } /** creates dictionaryGroups for [newLocales] with given [newSubDictTypes], trying to re-use existing dictionaries. @@ -345,7 +384,9 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { val dictionaryGroupsToClose: List synchronized(this) { dictionaryGroupsToClose = dictionaryGroups + stopObservingDictionaryChanges(dictionaryGroupsToClose) dictionaryGroups = listOf(DictionaryGroup()) + onDictionaryChanged() pendingMainDictionaryLoad.set(false) refreshMainDictionaryReadinessState() } @@ -536,9 +577,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { if (mValidSpellingWordWriteCache == null) return + val revision = spellingRevision.get() val lowerCaseWord = originalWord.lowercase(currentLocale) val lowerCaseValid = isValidSpellingWord(lowerCaseWord) - mValidSpellingWordWriteCache?.put(lowerCaseWord, lowerCaseValid) + cacheSpellingResult(mValidSpellingWordWriteCache, lowerCaseWord, lowerCaseValid, revision) val capitalWord = StringUtils.capitalizeFirstAndDowncaseRest(originalWord, currentLocale) val capitalValid = if (lowerCaseValid) { @@ -546,7 +588,16 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } else { isValidSpellingWord(capitalWord) } - mValidSpellingWordWriteCache?.put(capitalWord, capitalValid) + cacheSpellingResult(mValidSpellingWordWriteCache, capitalWord, capitalValid, revision) + } + + private fun cacheSpellingResult(cache: LruCache?, word: String, valid: Boolean, revision: Long) { + if (cache == null) return + // Use the cache's own monitor only on misses/writes; an older lookup must not repopulate + // a cache that was cleared while it was reading native dictionary contents. + synchronized(cache) { + if (revision == spellingRevision.get()) cache.put(word, valid) + } } override fun adjustConfidences(word: String, wasAutoCapitalized: Boolean) { @@ -672,7 +723,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // Apply session word boost to suggestion scores (for both typing and gesture modes) val boost = sessionWordBoost if (boost != null && (composedData.mTypedWord.isNotEmpty() || composedData.mIsBatchMode)) { - applySessionBoost(suggestionResults, boost) + applySessionBoost(suggestionResults, boost, ngramContext.isBeginningOfSentenceContext) } includeAtLeastTwoWordSuggestions(suggestionResults, suggestionsArray, composedData.mTypedWord) @@ -755,7 +806,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { for (info in dictionarySuggestions) { val word = info.word - if (!Settings.getValues().mSuggestEmojis && (info.isEmoji || info.mSourceDict?.mDictType == Dictionary.TYPE_EMOJI)) + if ((composedData.mIsBatchMode || !Settings.getValues().mSuggestEmojis) && (info.isEmoji || info.mSourceDict?.mDictType == Dictionary.TYPE_EMOJI)) continue if (isBlacklisted(word) || SupportedEmojis.isUnsupported(word)) // don't add blacklisted words and unsupported emojis continue @@ -907,7 +958,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { * that the user has typed recently. Since mScore is final, we must * remove and re-add entries with adjusted scores. */ - private fun applySessionBoost(results: SuggestionResults, boost: SessionWordBoost) { + private fun applySessionBoost(results: SuggestionResults, boost: SessionWordBoost, isBeginningOfSentence: Boolean) { val sessionMultiplier = when (Settings.getValues().mSuggestionBalance) { Settings.SUGGESTION_BALANCE_DICTIONARY_FOCUSED -> 0.25f Settings.SUGGESTION_BALANCE_CONSERVATIVE -> 0.50f @@ -918,7 +969,14 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { val boosted = mutableListOf() val toRemove = mutableListOf() for (info in results) { - val rawBoost = boost.getBoost(info.mWord) * sessionMultiplier * BOOST_SCORE_MULTIPLIER + val word = info.mWord + if (!isBeginningOfSentence && word.isNotEmpty() && Character.isUpperCase(word[0])) { + val lower = word.lowercase(currentlyPreferredDictionaryGroup.locale) + if (lower != word && (results.any { it.mWord == lower } || isValidSpellingWord(lower))) { + continue + } + } + val rawBoost = boost.getBoost(word) * sessionMultiplier * BOOST_SCORE_MULTIPLIER val boostAmount = rawBoost.coerceAtMost(MAX_PERSONALIZATION_BOOST.toFloat()) if (boostAmount > 0f) { toRemove.add(info) @@ -979,10 +1037,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // meaning that it always has default mConfidence. So we cannot choose to only check preferred // locale, and instead simply return true if word is in any of the available dictionaries override fun isValidSpellingWord(word: String): Boolean { + val revision = spellingRevision.get() + if (isBlacklisted(word)) return false mValidSpellingWordReadCache?.get(word)?.let { return it } mValidSpellingWordWriteCache?.get(word)?.let { return it } val result = dictionaryGroups.any { isValidWord(word, SPELLING_DICTIONARY_TYPES, it) } - mValidSpellingWordReadCache?.put(word, result) + cacheSpellingResult(mValidSpellingWordReadCache, word, result, revision) return result } @@ -1000,10 +1060,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { override fun isBlacklisted(word: String): Boolean = dictionaryGroups.any { it.isBlacklisted(word) } override fun removeWord(word: String) { + if (word.isEmpty()) return sessionWordBoost?.removeWord(word) for (dictionaryGroup in dictionaryGroups) { dictionaryGroup.removeWord(word) } + onDictionaryChanged() } override fun reloadBlacklist() { @@ -1017,11 +1079,17 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { val group = currentlyPreferredDictionaryGroup // Resolve the user dictionary first: if it isn't loaded we cannot add, and we must NOT // un-blacklist the word in that case (that would leave it neither blocked nor added). - val userDict = group.getSubDict(Dictionary.TYPE_USER) ?: return - group.removeFromBlacklist(word) // promoting a word un-blocks it - scope.launch { - // adding can throw IllegalArgumentException on some devices, see addToPersonalDictionaryIfInvalidButInHistory - runCatching { UserDictionary.Words.addWord(userDict.mContext, word, 250, null, group.locale) } + val userDict = group.getSubDict(Dictionary.TYPE_USER) as? UserBinaryDictionary + if (userDict == null) { + Log.w(TAG, "Cannot add word '$word': personal dictionary is unavailable") + return + } + val promotion = group.beginPromotion(word) + userDict.addWordToUserDictionary(word) { published -> + if (dictionaryGroups.any { it === group }) { + group.completePromotion(word, promotion, published) + if (published) onDictionaryChanged() + } } } @@ -1169,17 +1237,33 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { /** A group of dictionaries that work together for a single language. */ private class DictionaryGroup( val locale: Locale = Locale(""), - private var mainDict: Dictionary? = null, + @Volatile private var mainDict: Dictionary? = null, subDicts: Map = emptyMap(), context: Context? = null ) { private val subDicts: ConcurrentHashMap = ConcurrentHashMap(subDicts) + @Volatile + var onDictionaryChanged: () -> Unit = {} // Monitor for the blacklist set + file I/O. The previous code used // `synchronized(this)` inside an `apply { }` and `scope.launch { }` block, which // re-bound `this` to the inner receiver (the HashSet / CoroutineScope). Two // concurrent blacklist operations could then run without mutual exclusion. private val blacklistLock = Any() + private val pendingPromotions = mutableMapOf() + + fun beginPromotion(word: String): Any = synchronized(blacklistLock) { + Any().also { pendingPromotions[word.lowercase(locale)] = it } + } + + fun completePromotion(word: String, promotion: Any, published: Boolean) { + synchronized(blacklistLock) { + val lowercase = word.lowercase(locale) + if (pendingPromotions[lowercase] !== promotion) return + pendingPromotions.remove(lowercase) + if (published) removeFromBlacklist(word) + } + } /** Removes a word from all dictionaries in this group. If the word is in a read-only dictionary, it is blacklisted. */ fun removeWord(word: String) { @@ -1265,7 +1349,7 @@ private class DictionaryGroup( // --------------- Blacklist ------------------- // Limit parallelism to prevent excessive I/O operations - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(2)) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) // words cannot be (permanently) removed from some dictionaries, so we use a blacklist for "removing" words private val blacklistFile = if (context == null) null @@ -1291,6 +1375,7 @@ private class DictionaryGroup( Regex(Regex.escape(pattern)) } } + onDictionaryChanged() } private val blacklist = hashSetOf().apply { val file = blacklistFile @@ -1319,11 +1404,7 @@ private class DictionaryGroup( } fun isBlacklisted(word: String): Boolean { - val userDict = getSubDict(Dictionary.TYPE_USER) val lowercased = word.lowercase(locale) - if (userDict != null && (userDict.isInDictionary(word) || userDict.isInDictionary(lowercased))) { - return false - } val patterns = compiledBlacklistPatterns return patterns.any { it.matches(lowercased) } } @@ -1331,6 +1412,7 @@ private class DictionaryGroup( fun addToBlacklist(word: String) { val lowercase = word.lowercase(locale) synchronized(blacklistLock) { + pendingPromotions.remove(lowercase) if (!blacklist.add(lowercase)) return rebuildCompiledPatterns() } @@ -1423,6 +1505,7 @@ private class DictionaryGroup( // Close old dictionary if exists. Main dictionary can be assigned multiple times. val oldDict = mainDict mainDict = newMainDict + onDictionaryChanged() if (oldDict != null && newMainDict !== oldDict) oldDict.close() } diff --git a/app/src/main/java/helium314/keyboard/latin/KeyboardWrapperView.kt b/app/src/main/java/helium314/keyboard/latin/KeyboardWrapperView.kt index e4437de03..12939c255 100644 --- a/app/src/main/java/helium314/keyboard/latin/KeyboardWrapperView.kt +++ b/app/src/main/java/helium314/keyboard/latin/KeyboardWrapperView.kt @@ -134,22 +134,21 @@ class KeyboardWrapperView @JvmOverloads constructor( } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val keyboardView = findViewById(R.id.keyboard_view) - if (keyboardView == null) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - return - } - - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - val settingsValues = Settings.getValues() - val keyboardHeight = ResourceUtils.getKeyboardHeight(context.resources, settingsValues) - val padding = keyboardView.paddingTop + keyboardView.paddingBottom - val maxExpectedHeight = keyboardHeight + padding + val ocrCameraView = findViewById(R.id.ocr_camera_view) + val isOcrCameraVisible = ocrCameraView != null && (ocrCameraView.isShown || ocrCameraView.visibility == VISIBLE) + val baseHeight = if (isOcrCameraVisible) { + ResourceUtils.getOcrCameraHeight(context.resources, settingsValues) + } else { + ResourceUtils.getKeyboardHeight(context.resources, settingsValues) + } + val keyboardView = findViewById(R.id.keyboard_view) + val padding = if (keyboardView != null) keyboardView.paddingTop + keyboardView.paddingBottom else 0 + val maxExpectedHeight = baseHeight + padding - if (measuredHeight > maxExpectedHeight && maxExpectedHeight > 0) { - setMeasuredDimension(measuredWidth, maxExpectedHeight) - // Re-measure children with the capped height + if (maxExpectedHeight > 0) { + val width = MeasureSpec.getSize(widthMeasureSpec) + setMeasuredDimension(width, maxExpectedHeight) val exactHeightSpec = MeasureSpec.makeMeasureSpec(maxExpectedHeight, MeasureSpec.EXACTLY) for (i in 0 until childCount) { val child = getChildAt(i) @@ -157,7 +156,10 @@ class KeyboardWrapperView @JvmOverloads constructor( measureChildWithMargins(child, widthMeasureSpec, 0, exactHeightSpec, 0) } } + return } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec) } @SuppressLint("RtlHardcoded") diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index bdc154d87..c3060292f 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -98,6 +98,7 @@ import helium314.keyboard.latin.utils.LeakGuardHandlerWrapper; import helium314.keyboard.latin.utils.Log; import helium314.keyboard.latin.utils.RecapitalizeMode; +import helium314.keyboard.latin.utils.ResourceUtils; import helium314.keyboard.latin.utils.ScreenProfileProvider; import helium314.keyboard.latin.utils.StatsUtils; import helium314.keyboard.latin.utils.StatsUtilsManager; @@ -156,6 +157,8 @@ public class LatinIME extends InputMethodService implements final InputLogic mInputLogic = new InputLogic(this, this, mDictionaryFacilitator); private boolean mLastMainDictionaryAvailable = false; + private long mInputSessionGeneration; + // TODO: Move these {@link View}s to {@link KeyboardSwitcher}. View mInputView; private InsetsOutlineProvider mInsetsUpdater; @@ -210,6 +213,7 @@ public void onReceive(Context context, Intent intent) { private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this); private final OtpSuggestionManager mOtpSuggestionManager = new OtpSuggestionManager(this); + private final MathSuggestionManager mMathSuggestionManager = new MathSuggestionManager(this); private FloatingKeyboardManager mFloatingKeyboardManager; @@ -601,7 +605,6 @@ public VoiceInputManager getVoiceInputManager() { @Override public void onCreate() { sInstance = this; - helium314.keyboard.latin.gesture.SwipeGestureEngine.initialize(this); mSettings.startListener(); KeyboardIconsSet.Companion.getInstance().loadIcons(this); mRichImm = RichInputMethodManager.getInstance(); @@ -717,12 +720,6 @@ public void onUpdateMainDictionaryAvailability(final boolean isMainDictionaryAva mainKeyboardView.setMainDictionaryAvailability(isMainDictionaryAvailable); } mHandler.post(() -> { - if (isMainDictionaryAvailable) { - final Keyboard keyboard = mKeyboardSwitcher.getKeyboard(); - if (keyboard != null) { - mInputLogic.getSuggest().buildGestureIndexAsync(keyboard); - } - } if (mLastMainDictionaryAvailable != isMainDictionaryAvailable) { if (mInputLogic != null) { mInputLogic.getSuggest().clearNextWordSuggestionsCache(); @@ -814,7 +811,9 @@ public String getLocaleAndConfidenceInfo() { @Override public void onDestroy() { - helium314.keyboard.latin.gesture.SwipeGestureEngine.cancelIndexing(); + mInputSessionGeneration++; + helium314.keyboard.latin.utils.ProofreadHelper.cancelCurrentOperation(); + mKeyboardSwitcher.cancelOcrWork(); if (sInstance == this) { sInstance = null; } @@ -841,6 +840,7 @@ public void onDestroy() { try { unregisterReceiver(mDictionaryDumpBroadcastReceiver); } catch (Exception e) {} try { unregisterReceiver(mRestartAfterDeviceUnlockReceiver); } catch (Exception e) {} mStatsUtilsManager.onDestroy(this /* context */); + AudioAndHapticFeedbackManager.getInstance().onDestroy(); super.onDestroy(); deallocateMemory(); } @@ -1025,6 +1025,11 @@ public void setCandidatesView(final View view) { @Override public void onStartInput(final EditorInfo editorInfo, final boolean restarting) { + // Invalidate before UIHandler can defer this callback, even when EditorInfo is reused. + mInputSessionGeneration++; + helium314.keyboard.latin.utils.ProofreadHelper.cancelCurrentOperation(); + mClipboardHistoryManager.onStartInput(); + mKeyboardSwitcher.cancelOcrWork(); mHandler.onStartInput(editorInfo, restarting); } @@ -1036,6 +1041,8 @@ public void onStartInputView(final EditorInfo editorInfo, final boolean restarti @Override public void onFinishInputView(final boolean finishingInput) { + helium314.keyboard.latin.utils.ProofreadHelper.cancelCurrentOperation(); + mKeyboardSwitcher.cancelOcrWork(); StatsUtils.onFinishInputView(); mHandler.onFinishInputView(finishingInput); mStatsUtilsManager.onFinishInputView(); @@ -1053,6 +1060,10 @@ public void onFinishInputView(final boolean finishingInput) { @Override public void onFinishInput() { + mInputSessionGeneration++; + helium314.keyboard.latin.utils.ProofreadHelper.cancelCurrentOperation(); + mClipboardHistoryManager.onFinishInput(); + mKeyboardSwitcher.cancelOcrWork(); mHandler.onFinishInput(); // Auto-dismiss floating keyboard when the input session ends // (user navigated away from text input) @@ -1144,6 +1155,7 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart } mClipboardHistoryManager.onStartInputView(); + AudioAndHapticFeedbackManager.getInstance().onStartInputView(); mDictionaryFacilitator.onStartInput(); // Switch to the null consumer to handle cases leading to early exit below, for // which we @@ -1270,10 +1282,6 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart mainKeyboardView.closing(); suggest.setAutoCorrectionThreshold(currentSettingsValues.mAutoCorrectionThreshold); switcher.reloadMainKeyboard(); - final Keyboard keyboard = switcher.getKeyboard(); - if (keyboard != null) { - suggest.buildGestureIndexAsync(keyboard); - } if (needToCallLoadKeyboardLater) { // If we need to call loadKeyboard again later, we need to save its state now. // The @@ -1355,6 +1363,7 @@ public void onWindowShown() { @Override public void onWindowHidden() { + mKeyboardSwitcher.cancelOcrWork(); super.onWindowHidden(); Log.i(TAG, "onWindowHidden"); final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); @@ -1390,6 +1399,7 @@ void onFinishInputViewInternal(final boolean finishingInput) { } mOtpSuggestionManager.stop(); mClipboardHistoryManager.onFinishInputView(); + AudioAndHapticFeedbackManager.getInstance().onFinishInputView(); cleanupInternalStateForFinishInput(); } @@ -1551,6 +1561,24 @@ public void onComputeInsets(final InputMethodService.Insets outInsets) { if (mInputView == null) { return; } + if (mKeyboardSwitcher != null && mKeyboardSwitcher.isOcrCameraShowing()) { + final int inputWidth = mInputView.getWidth(); + final int inputHeight = mInputView.getHeight(); + if (inputWidth > 0 && inputHeight > 0) { + final View wrapperView = mKeyboardSwitcher.getWrapperView(); + int ocrHeight = (wrapperView != null && (wrapperView.isShown() || wrapperView.getVisibility() == View.VISIBLE)) ? wrapperView.getHeight() : 0; + if (ocrHeight <= 0) { + ocrHeight = ResourceUtils.getOcrCameraHeight(mDisplayContext.getResources(), Settings.getValues()); + } + final int visibleTopY = Math.max(0, inputHeight - ocrHeight); + outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_REGION; + outInsets.touchableRegion.set(0, visibleTopY, inputWidth, inputHeight + EXTENDED_TOUCHABLE_REGION_HEIGHT); + outInsets.contentTopInsets = visibleTopY; + outInsets.visibleTopInsets = visibleTopY; + mInsetsUpdater.setInsets(outInsets); + return; + } + } final View visibleKeyboardView = mKeyboardSwitcher.getWrapperView(); if (visibleKeyboardView == null) { return; @@ -2013,6 +2041,9 @@ private void setSuggestedWords(final SuggestedWords suggestedWords) { @Override public void setSuggestions(final SuggestedWords suggestedWords) { + if (tryShowMathSuggestion()) { + return; + } if (suggestedWords.isEmpty()) { // avoids showing clipboard suggestion when starting gesture typing // should be fine, as there will be another suggestion in a few ms @@ -2062,15 +2093,6 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { } } - if (suggestionInfo.isKindOf(helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo.KIND_CORRECTION) - && helium314.keyboard.latin.dictionary.Dictionary.DICTIONARY_USER_TYPED.equals( - suggestionInfo.mSourceDict != null ? suggestionInfo.mSourceDict.mDictType : "")) { - mInputLogic.getSuggest().recordAccepted( - suggestionInfo.mWord, - mInputLogic.getWordComposer().getComposedDataSnapshot().mInputPointers, - mKeyboardSwitcher.getKeyboard() - ); - } } /** @@ -2093,6 +2115,16 @@ public boolean tryShowOtpSuggestion() { return false; } + public boolean tryShowMathSuggestion() { + if (!hasSuggestionStripView()) return false; + final View mathView = mMathSuggestionManager.getMathSuggestionView(mSuggestionStripView); + if (mathView != null) { + mSuggestionStripView.setExternalSuggestionView(mathView, false); + return true; + } + return false; + } + public boolean tryShowClipboardSuggestion() { final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(), mSuggestionStripView); @@ -2121,8 +2153,8 @@ public void setNeutralSuggestionStrip() { return; } final SettingsValues currentSettings = mSettings.getCurrent(); - if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) { - // an external (OTP or clipboard) suggestion has been set + if (tryShowOtpSuggestion() || tryShowMathSuggestion() || tryShowClipboardSuggestion()) { + // an external (OTP, Math, or clipboard) suggestion has been set if (hasSuggestionStripView() && currentSettings.mAutoHideToolbar) mSuggestionStripView.setToolbarVisibility(false); return; @@ -2327,12 +2359,20 @@ public void hapticAndAudioFeedback(final int code, final int repeatCount, return; } } - final AudioAndHapticFeedbackManager feedbackManager = AudioAndHapticFeedbackManager.getInstance(); - if (repeatCount == 0) { - // TODO: Reconsider how to perform haptic feedback when repeating key. - feedbackManager.performHapticFeedback(keyboardView, hapticEvent); + float keyXRatio = 0.5f; + if (keyboardView != null) { + final helium314.keyboard.keyboard.Keyboard keyboard = keyboardView.getKeyboard(); + if (keyboard != null) { + final helium314.keyboard.keyboard.Key key = keyboard.getKey(code); + if (key != null && keyboard.mOccupiedWidth > 0) { + keyXRatio = (key.getX() + key.getWidth() / 2f) / (float) keyboard.mOccupiedWidth; + keyXRatio = Math.max(0f, Math.min(1f, keyXRatio)); + } + } } - feedbackManager.performAudioFeedback(code, hapticEvent); + final AudioAndHapticFeedbackManager feedbackManager = AudioAndHapticFeedbackManager.getInstance(); + feedbackManager.performHapticFeedback(keyboardView, hapticEvent); + feedbackManager.performAudioFeedback(code, hapticEvent, keyXRatio); } // Hooks for hardware keyboard @@ -2381,6 +2421,10 @@ public ClipboardHistoryManager getClipboardHistoryManager() { return mClipboardHistoryManager; } + public long getInputSessionGeneration() { + return mInputSessionGeneration; + } + void launchSettings() { mInputLogic.commitTyped(mSettings.getCurrent(), LastComposedWord.NOT_A_SEPARATOR); requestHideSelf(0); diff --git a/app/src/main/java/helium314/keyboard/latin/MathSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/MathSuggestionManager.kt new file mode 100644 index 000000000..cdf857240 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/MathSuggestionManager.kt @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin + +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import androidx.core.view.isGone +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.calculator.CalculatorHistoryManager +import helium314.keyboard.latin.calculator.MathEvaluator +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.databinding.OtpSuggestionBinding +import helium314.keyboard.latin.utils.InputTypeUtils +import helium314.keyboard.latin.utils.ToolbarKey + +class MathSuggestionManager(private val latinIME: LatinIME) { + + private var mathSuggestionView: View? = null + private var lastDismissedExpression: String? = null + private val historyManager by lazy { CalculatorHistoryManager.getInstance(latinIME) } + + fun getMathSuggestionView(parent: ViewGroup?): View? { + mathSuggestionView?.isGone = true + mathSuggestionView = null + if (parent == null) return null + if (!isEligible()) return null + + val connection = latinIME.mInputLogic?.connection ?: return null + val editor = latinIME.currentInputEditorInfo ?: return null + val inputConnection = latinIME.currentInputConnection ?: return null + val attributes = latinIME.mSettings.current.mInputAttributes + val sessionGeneration = latinIME.inputSessionGeneration + val editorIdentity = EditorIdentity(editor) + fun isCurrentEditor() = isEligible() + && latinIME.inputSessionGeneration == sessionGeneration + && latinIME.currentInputEditorInfo === editor + && EditorIdentity(editor) == editorIdentity + && latinIME.currentInputConnection === inputConnection + && latinIME.mInputLogic?.connection === connection + && latinIME.mSettings.current.mInputAttributes === attributes + val snapshot = readSnapshot(inputConnection, connection) ?: return null + if (!isCurrentEditor()) return null + val textBefore = snapshot.textBefore + if (!textBefore.contains('=')) return null + + val incognito = latinIME.mSettings.current.mIncognitoModeEnabled + val previousAnswer = if (incognito) null else historyManager.getLastAnswer()?.let { + runCatching { java.math.BigDecimal(it) }.getOrNull() + } + val match = MathEvaluator.evaluateInline(textBefore, previousAnswer) ?: return null + // A bounded read may begin halfway through an operand. Require a known left boundary. + if (match.startIndex == 0 && snapshot.cursor > textBefore.length) return null + if (match.expression == lastDismissedExpression) return null + + val binding = OtpSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) + val textView = binding.otpSuggestionText + latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it } + + textView.text = match.resultFormatted + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) + + textView.setOnClickListener { + // The strip can outlive an editor, a settings reload, or a pending selection update. + // Check privacy and ownership before making any new editor reads. + if (mathSuggestionView !== binding.root || !isCurrentEditor() + || readSnapshot(inputConnection, connection) != snapshot || !isCurrentEditor()) { + binding.root.isGone = true + if (mathSuggestionView === binding.root) mathSuggestionView = null + return@setOnClickListener + } + + // Use the normal input-state/cache machinery and a checked selection, rather than + // deleting an unchecked number of characters from whichever editor is now active. + latinIME.mInputLogic.finishInput() + connection.beginBatchEdit() + try { + val start = snapshot.cursor - textBefore.length + match.startIndex + if (!connection.setSelection(start, snapshot.cursor)) { + connection.setSelection(snapshot.cursor, snapshot.cursor) + return@setOnClickListener + } + if (inputConnection.getSelectedText(0)?.toString() != match.fullMatchedText) { + connection.setSelection(snapshot.cursor, snapshot.cursor) + return@setOnClickListener + } + if (!isCurrentEditor()) return@setOnClickListener + val replacement = match.resultFormatted + match.trailingWhitespace + latinIME.onTextInput(replacement) + if (!isCurrentEditor()) return@setOnClickListener + val applied = readSnapshot(inputConnection, connection) + val expectedBefore = (textBefore.take(match.startIndex) + replacement).takeLast(60) + if (applied?.cursor != start + replacement.length + || !applied.textBefore.endsWith(expectedBefore)) { + // RichInputConnection optimistically updates its cache even if an editor + // refuses commitText. Resync it, and never record an unapplied calculation. + val actual = inputConnection.getExtractedText(ExtractedTextRequest(), 0) + if (actual != null && isCurrentEditor()) { + connection.resetCachesUponCursorMoveAndReturnSuccess( + actual.startOffset + actual.selectionStart, + actual.startOffset + actual.selectionEnd, false) + } + return@setOnClickListener + } + } finally { + connection.endBatchEdit() + binding.root.isGone = true + if (mathSuggestionView === binding.root) mathSuggestionView = null + } + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback( + KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS + ) + if (!incognito && !latinIME.mSettings.current.mIncognitoModeEnabled) { + historyManager.addEntry(match.expression, match.resultFormatted) + } + lastDismissedExpression = match.expression + } + + val closeButton = binding.otpSuggestionClose + closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) + closeButton.setOnClickListener { + lastDismissedExpression = match.expression + removeMathSuggestion() + } + + val colors = latinIME.mSettings.current.mColors + textView.setTextColor(colors.get(ColorType.KEY_TEXT)) + colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) + colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND) + + mathSuggestionView = binding.root + return mathSuggestionView + } + + fun removeMathSuggestion() { + val view = mathSuggestionView ?: return + mathSuggestionView = null + if (view.parent != null && !view.isGone) { + latinIME.setNeutralSuggestionStrip() + latinIME.mHandler.postResumeSuggestions(false) + } + view.isGone = true + } + + private fun isEligible(): Boolean { + if (!latinIME.currentInputStarted) return false + val editor = latinIME.currentInputEditorInfo ?: return false + val settings = latinIME.mSettings.current + if (!settings.mInlineMathCalculation) return false + val type = editor.inputType + val inputClass = type and InputType.TYPE_MASK_CLASS + return (inputClass == InputType.TYPE_CLASS_TEXT || inputClass == InputType.TYPE_CLASS_NUMBER) + && !InputTypeUtils.isPasswordInputType(type) + && !InputTypeUtils.isVisiblePasswordInputType(type) + && !InputTypeUtils.isUriOrEmailType(type) + && (inputClass != InputType.TYPE_CLASS_TEXT + || type and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS == 0) + && editor.imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING == 0 + && !settings.mInputAttributes.mIsPasswordField + && !settings.mInputAttributes.mNoLearning + } + + private data class EditorIdentity( + val packageName: String?, + val fieldId: Int, + val fieldName: String?, + val inputType: Int, + val imeOptions: Int, + val privateImeOptions: String? + ) { + constructor(editor: EditorInfo) : this(editor.packageName, editor.fieldId, editor.fieldName, + editor.inputType, editor.imeOptions, editor.privateImeOptions) + } + + private data class Snapshot(val cursor: Int, val textBefore: String) + + private fun readSnapshot(raw: InputConnection, connection: RichInputConnection): Snapshot? { + // Like RichInputConnection.reloadCursorPosition, request selection metadata only. + // Cached text alone cannot detect edits or cursor moves whose callbacks are still pending. + val extracted = raw.getExtractedText(ExtractedTextRequest(), 0) ?: return null + if (extracted.selectionStart < 0 || extracted.selectionStart != extracted.selectionEnd) return null + val cursor = extracted.startOffset + extracted.selectionStart + if (cursor != connection.expectedSelectionStart + || cursor != connection.expectedSelectionEnd) return null + val before = raw.getTextBeforeCursor(60, 0)?.toString() ?: return null + if (before.length > cursor) return null + return Snapshot(cursor, before) + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/NgramContext.java b/app/src/main/java/helium314/keyboard/latin/NgramContext.java index 9a2f5c2ce..7ff2b1006 100644 --- a/app/src/main/java/helium314/keyboard/latin/NgramContext.java +++ b/app/src/main/java/helium314/keyboard/latin/NgramContext.java @@ -217,15 +217,20 @@ public boolean isNthPrevWordBeginningOfSentence(final int n) { public void outputToArray(final int[][] codePointArrays, final boolean[] isBeginningOfSentenceArray) { - for (int i = 0; i < mPrevWordsCount; i++) { - final WordInfo wordInfo = mPrevWordsInfo[i]; - if (wordInfo == null || !wordInfo.isValid()) { + for (int i = 0; i < codePointArrays.length; i++) { + if (i < mPrevWordsCount) { + final WordInfo wordInfo = mPrevWordsInfo[i]; + if (wordInfo == null || !wordInfo.isValid()) { + codePointArrays[i] = new int[0]; + isBeginningOfSentenceArray[i] = false; + continue; + } + codePointArrays[i] = StringUtils.toCodePointArray(wordInfo.mWord); + isBeginningOfSentenceArray[i] = wordInfo.mIsBeginningOfSentence; + } else { codePointArrays[i] = new int[0]; isBeginningOfSentenceArray[i] = false; - continue; } - codePointArrays[i] = StringUtils.toCodePointArray(wordInfo.mWord); - isBeginningOfSentenceArray[i] = wordInfo.mIsBeginningOfSentence; } } diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt index 28a506b8f..fedc50cb6 100644 --- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt +++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt @@ -11,6 +11,7 @@ import com.android.inputmethod.latin.utils.BinaryDictionaryUtils import helium314.keyboard.keyboard.Keyboard import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.calculator.MathEvaluator import helium314.keyboard.latin.common.ComposedData import helium314.keyboard.latin.common.Constants import helium314.keyboard.latin.common.InputPointers @@ -19,7 +20,6 @@ import helium314.keyboard.latin.define.DebugFlags import helium314.keyboard.latin.define.DecoderSpecificConstants.SHOULD_AUTO_CORRECT_USING_NON_WHITE_LISTED_SUGGESTION import helium314.keyboard.latin.define.DecoderSpecificConstants.SHOULD_REMOVE_PREVIOUSLY_REJECTED_SUGGESTION import helium314.keyboard.latin.dictionary.Dictionary -import helium314.keyboard.latin.gesture.SwipeGestureEngine import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.settings.SettingsValuesForSuggestion import helium314.keyboard.latin.suggestions.SuggestionStripView @@ -27,8 +27,6 @@ import helium314.keyboard.latin.utils.AutoCorrectionUtils import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.JniUtils import helium314.keyboard.latin.utils.SuggestionResults -import helium314.keyboard.latin.utils.ExecutorUtils -import java.util.concurrent.atomic.AtomicInteger import java.util.Locale import kotlin.math.min @@ -41,43 +39,8 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { private val mPlausibilityThreshold = 0f // Use LRU cache with size limit instead of HashMap to avoid clearing and preserve frequently used entries // Cache size of 50 should cover most typing scenarios while limiting memory usage - private val nextWordSuggestionsCache = object : LruCache(50) { - override fun entryRemoved(evicted: Boolean, key: NgramContext, oldValue: SuggestionResults, newValue: SuggestionResults?) { - // Optionally log evicted entries for debugging - } - } - // Java fallback index, rebuilt only when keyboard geometry changes. - @Volatile private var gestureIndex: SwipeGestureEngine.GestureIndex? = null - @Volatile private var gestureIndexFingerprint: Int = 0 - private val buildingFingerprint = AtomicInteger(0) - - fun buildGestureIndexAsync(keyboard: Keyboard) { - if (!Settings.getValues().mGestureInputEnabled) return - val fingerprint = SwipeGestureEngine.layoutFingerprint(keyboard) - if (fingerprint == 0) return - if ((gestureIndex != null && gestureIndexFingerprint == fingerprint) - || buildingFingerprint.get() == fingerprint - ) return - if (!buildingFingerprint.compareAndSet(0, fingerprint)) return - - ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { - try { - val index = SwipeGestureEngine.buildIndex(mDictionaryFacilitator, keyboard) - gestureIndex = index - gestureIndexFingerprint = fingerprint - } catch (t: Throwable) { - Log.e(TAG, "Failed to build Java gesture index", t) - gestureIndex = null - } finally { - buildingFingerprint.compareAndSet(fingerprint, 0) - } - } - } - - fun recordAccepted(word: String, pointers: InputPointers, keyboard: Keyboard) { - SwipeGestureEngine.recordAccepted(word, pointers, keyboard, gestureIndex) - } - + private data class CachedNextWords(val revision: Long, val results: SuggestionResults) + private val nextWordSuggestionsCache = LruCache(50) // Cached scoreLimit to avoid repeated Settings lookups in hot path // The read-then-write of (mLastScoreLimitUpdateTime, mCachedScoreLimitForAutocorrect) // is guarded by `synchronized(this)` in shouldBeAutoCorrected() to make the update atomic @@ -88,8 +51,6 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { // cache cleared whenever LatinIME.loadSettings is called, notably on changing layout and switching input fields fun clearNextWordSuggestionsCache() { nextWordSuggestionsCache.evictAll() - gestureIndex = null - buildingFingerprint.set(0) // Also reset scoreLimit cache to force refresh on next use synchronized(this) { mLastScoreLimitUpdateTime = 0 @@ -172,6 +133,24 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { if (!TextUtils.isEmpty(capitalizedTypedWord)) { suggestionsContainer.add(0, typedWordInfo) } + + // Inline math calculation: offer calculated result chip if typing an arithmetic expression ending in '=' + val mathMatch = if (Settings.getValues().mInlineMathCalculation) + MathEvaluator.evaluateInline(typedWordString) else null + if (mathMatch != null) { + val mathSuggestion = SuggestedWordInfo( + mathMatch.resultFormatted, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_CORRECTION, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + val insertIdx = if (suggestionsContainer.isNotEmpty()) 1 else 0 + suggestionsContainer.add(insertIdx, mathSuggestion) + } + val suggestionsList = if (SuggestionStripView.DEBUG_SUGGESTIONS && suggestionsContainer.isNotEmpty()) getSuggestionsInfoListWithDebugInfo(capitalizedTypedWord, suggestionsContainer) else suggestionsContainer @@ -275,6 +254,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { // certainly intentional (and careful input) || (wordComposer.isMostlyCaps && !wordComposer.isAllUpperCase) // We never auto-correct when suggestions are resumed because it would be unexpected || wordComposer.isResumed // If we don't have a main dictionary, we never want to auto-correct. The reason + || isDeveloperTokenOrSpecialSyntax(consideredWord) // for this is, the user may have a contact whose name happens to match a valid // word in their language, and it will unexpectedly auto-correct. For example, if // the user types in English with no dictionary and has a "Will" in their contact @@ -290,6 +270,29 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { // mFirstSuggestionExceedsConfidenceThreshold is always set to false, so currently this branch is useless return true to true } + + val lowerConsidered = consideredWord.lowercase(Locale.ROOT) + val expectedContraction = COMMON_CONTRACTIONS[lowerConsidered] + if (expectedContraction != null && typedWordInfo == null) { + // If typed word matches a missing-apostrophe contraction (e.g. dont -> don't), promote it + val contractionMatch = suggestionResults.firstOrNull { it.mWord.equals(expectedContraction, ignoreCase = true) } + if (contractionMatch != null || firstSuggestion.mWord.equals(expectedContraction, ignoreCase = true)) { + return true to true + } + } + + // For short words (<= 3 chars) not in the dictionary (e.g. Ab, yt, tg, db), + // prevent single-letter substitution by common dictionary unigrams unless it's a known user history word or contraction. + if (typedWordInfo == null && consideredWord.length <= 3) { + val isExactOrCaseMatch = firstSuggestion.mWord.equals(consideredWord, ignoreCase = true) + val isUserHistory = firstSuggestion.mSourceDict?.mDictType == Dictionary.TYPE_USER_HISTORY + val isWhitelist = firstSuggestion.isKindOf(SuggestedWordInfo.KIND_WHITELIST) + val isShortcut = firstSuggestion.isKindOf(SuggestedWordInfo.KIND_SHORTCUT) + if (!isExactOrCaseMatch && !isUserHistory && !isWhitelist && !isShortcut && expectedContraction == null) { + return true to false + } + } + if (!AutoCorrectionUtils.suggestionExceedsThreshold(firstSuggestion, consideredWord, mAutoCorrectionThreshold)) { // Score is too low for autocorrect — but for long words, the normalized score // formula penalizes proportionally (weight = 1 - editDist/len), so a single typo @@ -402,31 +405,13 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { inputStyle: Int, sequenceNumber: Int ): SuggestedWords { val pointers = wordComposer.composedDataSnapshot.mInputPointers - val method = settingsValuesForSuggestion.mGestureMethod - val useFallback = "fallback" == method || !JniUtils.sHaveNativeGestureLib - val suggestionResults = if (useFallback) { - val fingerprint = SwipeGestureEngine.layoutFingerprint(keyboard) - val index = gestureIndex - if (index == null || gestureIndexFingerprint != fingerprint) { - buildGestureIndexAsync(keyboard) - SuggestionResults(1, false, false) - } else { - val predictionSet = if (ngramContext.isValid) { - mDictionaryFacilitator.getSuggestionResults( - ComposedData(InputPointers(32), false, ""), ngramContext, keyboard, - settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle - ).map { it.mWord.lowercase(Locale.ROOT) }.toSet() - } else { - emptySet() - } - SwipeGestureEngine.rankByIndex(index, pointers, keyboard, SuggestedWords.MAX_SUGGESTIONS, predictionSet) - } - } else { - mDictionaryFacilitator.getSuggestionResults( - wordComposer.composedDataSnapshot, ngramContext, keyboard, - settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle - ) + if (!JniUtils.sHaveNativeGestureLib) { + return SuggestedWords.getEmptyInstance() } + val suggestionResults = mDictionaryFacilitator.getSuggestionResults( + wordComposer.composedDataSnapshot, ngramContext, keyboard, + settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle + ) filterMultiWordSuggestions(suggestionResults, Settings.getValues().mDisableMultiWordSuggestions) if (!Settings.getValues().mSuggestEmojis) { suggestionResults.removeAll { it.isEmoji || it.mSourceDict?.mDictType == Dictionary.TYPE_EMOJI } @@ -506,12 +491,13 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { /** get suggestions based on the current ngram context, with an empty typed word (that's what next word suggestions do) */ private fun getNextWordSuggestions(ngramContext: NgramContext, keyboard: Keyboard, inputStyle: Int, settingsValuesForSuggestion: SettingsValuesForSuggestion): SuggestionResults { + val revision = mDictionaryFacilitator.dictionaryRevision val cachedResults = nextWordSuggestionsCache.get(ngramContext) - if (cachedResults != null) { + if (cachedResults != null && cachedResults.revision == revision) { if (BuildConfig.DEBUG && DebugFlags.SCORE_AUDIT) { - Log.i("ScoreAudit", "nextWord: cacheHit=true prevCount=${ngramContext.prevWordCount} isBOS=${ngramContext.isBeginningOfSentenceContext} count=${cachedResults.size}") + Log.i("ScoreAudit", "nextWord: cacheHit=true prevCount=${ngramContext.prevWordCount} isBOS=${ngramContext.isBeginningOfSentenceContext} count=${cachedResults.results.size}") } - return cachedResults.copy() + return cachedResults.results.copy() } val newResults = mDictionaryFacilitator.getSuggestionResults(ComposedData(InputPointers(1), false, ""), ngramContext, keyboard, settingsValuesForSuggestion, SESSION_ID_TYPING, inputStyle) @@ -523,7 +509,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { val mainLoadPending = mDictionaryFacilitator.isMainDictionaryLoadPending() val shouldCache = newResults.isNotEmpty() || mainReady || !mainLoadPending if (shouldCache) { - nextWordSuggestionsCache.put(ngramContext, newResults.copy()) + nextWordSuggestionsCache.put(ngramContext, CachedNextWords(revision, newResults.copy())) } return newResults } @@ -576,9 +562,36 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { private const val SUPPRESS_SUGGEST_THRESHOLD = -2000000000 private const val MAXIMUM_AUTO_CORRECT_LENGTH_FOR_GERMAN = 12 - // TODO: should we add Finnish here? private val sLanguageToMaximumAutoCorrectionWithSpaceLength = hashMapOf(Locale.GERMAN.language to MAXIMUM_AUTO_CORRECT_LENGTH_FOR_GERMAN) + private val COMMON_CONTRACTIONS = mapOf( + "dont" to "don't", "cant" to "can't", "wont" to "won't", + "im" to "I'm", "ive" to "I've", "id" to "I'd", + "theyre" to "they're", "youre" to "you're", "weve" to "we've", "theyve" to "they've", + "isnt" to "isn't", "arent" to "aren't", "wasnt" to "wasn't", "werent" to "weren't", + "couldnt" to "couldn't", "shouldnt" to "shouldn't", "wouldnt" to "wouldn't", + "didnt" to "didn't", "doesnt" to "doesn't", "hadnt" to "hadn't", + "havent" to "haven't", "hasnt" to "hasn't", "whats" to "what's", "thats" to "that's", + "theres" to "there's", "heres" to "here's", "wheres" to "where's", "whos" to "who's", + "lets" to "let's", "itll" to "it'll", "youll" to "you'll", "theyll" to "they'll" + ) + + private fun isDeveloperTokenOrSpecialSyntax(word: String): Boolean { + if (word.isEmpty()) return false + if (word.startsWith('#') || word.startsWith('@') || word.startsWith('/') || word.startsWith('.')) return true + if (word.contains('/') || word.contains('\\') || word.contains('.') || word.contains('_') || word.contains('-')) return true + var hasLower = false + for (i in 0 until word.length) { + val c = word[i] + if (c.isLowerCase()) { + hasLower = true + } else if (hasLower && c.isUpperCase()) { + return true + } + } + return false + } + private fun getTransformedSuggestedWordInfoList( wordComposer: WordComposer, results: SuggestionResults, trailingSingleQuotesCount: Int, defaultLocale: Locale, keyboard: Keyboard diff --git a/app/src/main/java/helium314/keyboard/latin/calculator/CalculatorHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/calculator/CalculatorHistoryManager.kt new file mode 100644 index 000000000..86565b23c --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/calculator/CalculatorHistoryManager.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.calculator + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import helium314.keyboard.latin.utils.prefs +import org.json.JSONArray +import org.json.JSONObject + +data class HistoryEntry( + val id: Long, + val expression: String, + val result: String, + val timestamp: Long +) + +class CalculatorHistoryManager private constructor(private val context: Context) { + + private val prefs: SharedPreferences = context.prefs() + private val memoryHistory = mutableListOf() + + init { + loadHistoryFromPrefs() + } + + @Synchronized + fun addEntry(expression: String, result: String) { + if (expression.isBlank() || result.isBlank() || result == "Error") return + val entry = HistoryEntry( + id = System.currentTimeMillis(), + expression = expression.trim(), + result = result.trim(), + timestamp = System.currentTimeMillis() + ) + // Remove identical duplicate if it was the immediately preceding entry + if (memoryHistory.isNotEmpty() && memoryHistory.first().expression == entry.expression) { + memoryHistory.removeAt(0) + } + memoryHistory.add(0, entry) + if (memoryHistory.size > MAX_HISTORY_ITEMS) { + memoryHistory.removeAt(memoryHistory.size - 1) + } + saveHistoryToPrefs() + } + + @Synchronized + fun getHistory(): List { + return memoryHistory.toList() + } + + @Synchronized + fun getLastAnswer(): String? { + return memoryHistory.firstOrNull()?.result + } + + @Synchronized + fun clearHistory() { + memoryHistory.clear() + prefs.edit { remove(PREF_CALCULATOR_HISTORY) } + } + + private fun loadHistoryFromPrefs() { + val jsonString = prefs.getString(PREF_CALCULATOR_HISTORY, null) ?: return + try { + val jsonArray = JSONArray(jsonString) + memoryHistory.clear() + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + memoryHistory.add( + HistoryEntry( + id = obj.optLong("id", System.currentTimeMillis()), + expression = obj.optString("expression", ""), + result = obj.optString("result", ""), + timestamp = obj.optLong("timestamp", 0L) + ) + ) + } + } catch (_: Exception) { + // Ignore corrupted JSON + } + } + + private fun saveHistoryToPrefs() { + try { + val jsonArray = JSONArray() + for (entry in memoryHistory) { + val obj = JSONObject().apply { + put("id", entry.id) + put("expression", entry.expression) + put("result", entry.result) + put("timestamp", entry.timestamp) + } + jsonArray.put(obj) + } + prefs.edit { putString(PREF_CALCULATOR_HISTORY, jsonArray.toString()) } + } catch (_: Exception) { + } + } + + companion object { + private const val PREF_CALCULATOR_HISTORY = "pref_calculator_history_json" + private const val MAX_HISTORY_ITEMS = 30 + + @Volatile + private var instance: CalculatorHistoryManager? = null + + fun getInstance(context: Context): CalculatorHistoryManager { + return instance ?: synchronized(this) { + val appCtx = runCatching { context.applicationContext }.getOrNull() ?: context + instance ?: CalculatorHistoryManager(appCtx).also { instance = it } + } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/calculator/MathEvaluator.kt b/app/src/main/java/helium314/keyboard/latin/calculator/MathEvaluator.kt new file mode 100644 index 000000000..15e724031 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/calculator/MathEvaluator.kt @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.calculator + +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.ArrayDeque +import java.util.Locale +import kotlin.math.pow + +sealed class MathResult { + data class Success( + val value: BigDecimal, + val formatted: String, + val rawString: String + ) : MathResult() + + data class Error(val message: String) : MathResult() +} + +data class InlineMathMatch( + val fullMatchedText: String, + val expression: String, + val resultFormatted: String, + val resultValue: BigDecimal, + val startIndex: Int, + val trailingWhitespace: String +) + +object MathEvaluator { + + private val MATH_CONTEXT = MathContext(16, RoundingMode.HALF_UP) + private val HUNDRED = BigDecimal("100") + + private val INLINE_MATH_REGEX = Regex( + """(?:^|[\s=;,])([+\-−]*(?:[0-9.(])[0-9+\-*/^%.()×÷− ]*[0-9%)])\s*=([ \t\r\n]*)$""" + ) + + fun evaluate(expression: String, previousAnswer: BigDecimal? = null): MathResult { + if (expression.isBlank()) { + return MathResult.Error("Empty expression") + } + + try { + val normalized = normalizeExpression(expression, previousAnswer) + val tokens = tokenize(normalized) + if (tokens.isEmpty()) return MathResult.Error("Empty expression") + + val rpn = shuntingYard(tokens) + val result = evaluateRpn(rpn) + val formatted = formatResult(result) + return MathResult.Success( + value = result, + formatted = formatted, + rawString = result.stripTrailingZeros().toPlainString() + ) + } catch (e: ArithmeticException) { + return MathResult.Error(e.message ?: "Math error") + } catch (e: IllegalArgumentException) { + return MathResult.Error(e.message ?: "Invalid expression") + } catch (e: Exception) { + return MathResult.Error("Error") + } + } + + fun evaluateInline(text: String, previousAnswer: BigDecimal? = null): InlineMathMatch? { + if (text.isBlank() || !text.contains('=')) return null + val match = INLINE_MATH_REGEX.find(text) ?: return null + + val expressionGroup = match.groups[1] ?: return null + val exprGroup = expressionGroup.value.trim() + // Ensure the expression actually contains at least one operator so pure "5=" doesn't trigger + if (!exprGroup.any { it in "+-*/×÷−^%" }) return null + + val result = evaluate(exprGroup, previousAnswer) + if (result is MathResult.Success) { + return InlineMathMatch( + fullMatchedText = text.substring(expressionGroup.range.first), + expression = exprGroup, + resultFormatted = result.formatted, + resultValue = result.value, + startIndex = expressionGroup.range.first, + trailingWhitespace = match.groupValues[2] + ) + } + return null + } + + fun formatResult(value: BigDecimal, locale: Locale = Locale.getDefault()): String { + val stripped = value.stripTrailingZeros() + val absVal = stripped.abs() + val isZero = stripped.compareTo(BigDecimal.ZERO) == 0 + + if (isZero) return "0" + + val plain = stripped.toPlainString() + // If huge (> 10^14) or tiny (< 10^-5), format with scientific notation + if (absVal >= BigDecimal("100000000000000") || (absVal <= BigDecimal("0.00001") && !isZero)) { + val symbols = DecimalFormatSymbols(locale) + val df = DecimalFormat("0.######E0", symbols) + return df.format(stripped.toDouble()) + } + + return plain + } + + private fun normalizeExpression(raw: String, previousAnswer: BigDecimal?): String { + var expr = raw + .replace('×', '*') + .replace('÷', '/') + .replace('−', '-') + .replace(',', '.') + .filterNot { it.isWhitespace() } + + if (previousAnswer != null) { + val ansStr = previousAnswer.stripTrailingZeros().toPlainString() + expr = expr.replace("Ans", ansStr).replace("ans", ansStr) + } else { + expr = expr.replace("Ans", "0").replace("ans", "0") + } + + // Insert implicit multiplication: e.g. 5( -> 5*( , )( -> )*( , )5 -> )*5 + val sb = StringBuilder() + for (i in expr.indices) { + val c = expr[i] + if (i > 0) { + val prev = expr[i - 1] + if ((prev.isDigit() || prev == ')' || prev == '%') && c == '(') { + sb.append('*') + } else if (prev == ')' && (c.isDigit() || c == '.')) { + sb.append('*') + } + } + sb.append(c) + } + return sb.toString() + } + + private sealed class Token { + data class Number(val value: BigDecimal) : Token() + data class Op(val symbol: Char, val precedence: Int, val isRightAssociative: Boolean = false) : Token() + data class Unary(val symbol: Char) : Token() + data object OpenParen : Token() + data object CloseParen : Token() + data object Percent : Token() + } + + private fun tokenize(expr: String): List { + val tokens = mutableListOf() + var i = 0 + var expectUnary = true + + while (i < expr.length) { + val c = expr[i] + + when { + c.isDigit() || c == '.' -> { + require(expectUnary) { "Missing operator" } + val start = i + var hasDot = (c == '.') + i++ + while (i < expr.length && (expr[i].isDigit() || expr[i] == '.')) { + if (expr[i] == '.') { + if (hasDot) break + hasDot = true + } + i++ + } + val numStr = expr.substring(start, i) + val num = try { + BigDecimal(numStr) + } catch (_: Exception) { + throw IllegalArgumentException("Invalid number: $numStr") + } + tokens.add(Token.Number(num)) + expectUnary = false + } + c == '(' -> { + require(expectUnary) { "Missing operator" } + tokens.add(Token.OpenParen) + expectUnary = true + i++ + } + c == ')' -> { + require(!expectUnary) { "Missing operand" } + tokens.add(Token.CloseParen) + expectUnary = false + i++ + } + c == '%' -> { + require(!expectUnary) { "Missing operand for %" } + tokens.add(Token.Percent) + expectUnary = false + i++ + } + c == '+' || c == '-' || c == '*' || c == '/' || c == '^' -> { + if (expectUnary) { + if (c == '-' || c == '+') { + tokens.add(Token.Unary(c)) + i++ + continue + } else { + throw IllegalArgumentException("Unexpected operator: $c") + } + } + + val precedence = when (c) { + '+', '-' -> 1 + '*', '/' -> 2 + '^' -> 4 + else -> 0 + } + val rightAssoc = (c == '^') + tokens.add(Token.Op(c, precedence, rightAssoc)) + expectUnary = true + i++ + } + else -> { + throw IllegalArgumentException("Unexpected character: $c") + } + } + } + require(!expectUnary) { "Missing operand" } + return tokens + } + + private fun shuntingYard(tokens: List): List { + val output = mutableListOf() + val opStack = ArrayDeque() + + for (token in tokens) { + when (token) { + is Token.Number -> output.add(token) + is Token.Percent -> output.add(token) + // A prefix operator starts an operand: it must not pop a pending binary operator. + is Token.Unary -> opStack.push(token) + is Token.Op -> { + while (opStack.isNotEmpty()) { + val top = opStack.peek() + val precedence = when (top) { + is Token.Op -> top.precedence + is Token.Unary -> 3 + else -> break + } + if ((!token.isRightAssociative && token.precedence <= precedence) || + (token.isRightAssociative && token.precedence < precedence) + ) { + output.add(opStack.pop()) + } else break + } + opStack.push(token) + } + is Token.OpenParen -> opStack.push(token) + is Token.CloseParen -> { + var foundOpen = false + while (opStack.isNotEmpty()) { + val top = opStack.pop() + if (top is Token.OpenParen) { + foundOpen = true + break + } else { + output.add(top) + } + } + require(foundOpen) { "Mismatched parentheses" } + } + } + } + + while (opStack.isNotEmpty()) { + val top = opStack.pop() + require(top !is Token.OpenParen) { "Mismatched parentheses" } + output.add(top) + } + + return output + } + + private data class Operand(val value: BigDecimal, val isPercentage: Boolean = false) + + private fun evaluateRpn(rpn: List): BigDecimal { + val stack = ArrayDeque() + + for (token in rpn) { + when (token) { + is Token.Number -> stack.push(Operand(token.value)) + is Token.Percent -> { + if (stack.isEmpty()) throw IllegalArgumentException("Missing operand for %") + val current = stack.pop() + stack.push(Operand(current.value.divide(HUNDRED, MATH_CONTEXT), true)) + } + is Token.Unary -> { + require(stack.isNotEmpty()) { "Missing unary operand" } + val current = stack.pop() + stack.push(current.copy(value = if (token.symbol == '-') current.value.negate() + else current.value)) + } + is Token.Op -> { + if (stack.size < 2) throw IllegalArgumentException("Invalid expression format") + val right = stack.pop() + val a = stack.pop().value + // Only a percentage used directly by + or - is relative to their left operand. + val b = if (right.isPercentage && token.symbol in "+-") + a.multiply(right.value, MATH_CONTEXT) else right.value + val res = when (token.symbol) { + '+' -> a.add(b, MATH_CONTEXT) + '-' -> a.subtract(b, MATH_CONTEXT) + '*' -> a.multiply(b, MATH_CONTEXT) + '/' -> { + if (b.compareTo(BigDecimal.ZERO) == 0) { + throw ArithmeticException("Cannot divide by zero") + } + a.divide(b, MATH_CONTEXT) + } + '^' -> { + val bDouble = b.toDouble() + if (b.scale() <= 0 || b.stripTrailingZeros().scale() <= 0) { + val intExp = b.toInt() + if (intExp in -999..999) { + if (intExp < 0) { + BigDecimal.ONE.divide(a.pow(-intExp, MATH_CONTEXT), MATH_CONTEXT) + } else { + a.pow(intExp, MATH_CONTEXT) + } + } else { + BigDecimal(a.toDouble().pow(bDouble), MATH_CONTEXT) + } + } else { + BigDecimal(a.toDouble().pow(bDouble), MATH_CONTEXT) + } + } + else -> throw IllegalArgumentException("Unknown operator: ${token.symbol}") + } + stack.push(Operand(res)) + } + else -> {} + } + } + + require(stack.size == 1) { "Invalid expression format" } + return stack.pop().value + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt index 91f013d98..79bccf540 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt +++ b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt @@ -110,12 +110,27 @@ class DynamicColors(context: Context, override val themeStyle: String, override else ContextCompat.getColor(context, android.R.color.system_neutral1_0) private fun getFunctionalKey(context: Context) = if (isNight(context)) ContextCompat.getColor(context, android.R.color.system_accent2_300) else ContextCompat.getColor(context, android.R.color.system_accent2_200) - private fun getKeyText(context: Context) = if (isNight(context)) ContextCompat.getColor(context, android.R.color.system_neutral1_50) - else ContextCompat.getColor(context, android.R.color.system_accent3_900) - private fun getKeyHintText(context: Context) = if (isNight(context)) getKeyText(context) - else ContextCompat.getColor(context, android.R.color.system_accent3_700) + private fun getKeyText(context: Context): Int { + val keyBg = getKeyBackground(context) + if (isNight(context)) { + val color = ContextCompat.getColor(context, android.R.color.system_neutral1_50) + return if (ColorUtils.calculateContrast(color, keyBg) < 4.5) Color.WHITE else color + } else { + val color = ContextCompat.getColor(context, android.R.color.system_accent3_900) + return if (ColorUtils.calculateContrast(color, keyBg) < 4.5) ContextCompat.getColor(context, android.R.color.system_neutral1_900) else color + } + } + private fun getKeyHintText(context: Context): Int { + val keyBg = getKeyBackground(context) + if (isNight(context)) { + return getKeyText(context) + } else { + val color = ContextCompat.getColor(context, android.R.color.system_accent3_700) + return if (ColorUtils.calculateContrast(color, keyBg) < 3.0) ContextCompat.getColor(context, android.R.color.system_neutral1_700) else color + } + } private fun getSpaceBarText(context: Context) = if (isNight(context)) ColorUtils.setAlphaComponent(ContextCompat.getColor(context, android.R.color.system_neutral1_50), 127) - else ColorUtils.setAlphaComponent(ContextCompat.getColor(context, android.R.color.system_accent3_700), 127) + else ColorUtils.setAlphaComponent(getKeyText(context), 127) override fun haveColorsChanged(context: Context) = accent != getAccent(context) diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java index 161737bc0..c80be8ded 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java @@ -29,12 +29,16 @@ import helium314.keyboard.latin.utils.ExecutorUtils; import java.io.File; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -94,11 +98,38 @@ abstract public class ExpandableBinaryDictionary extends Dictionary { /** Indicates whether a task for reloading the dictionary has been scheduled. */ private final AtomicBoolean mIsReloading; - /** Indicates whether the current dictionary needs to be recreated. */ - private boolean mNeedsToRecreate; + private final AtomicLong mRecreateGeneration = new AtomicLong(); + private volatile long mLoadedRecreateGeneration; private final ReentrantReadWriteLock mLock; private final Object mIterationLock = new Object(); + private volatile boolean mClosed; + private final ArrayDeque mPendingWriteTasks = new ArrayDeque<>(); + public interface DictionaryChangeListener { + void onDictionaryChanged(boolean affectsIndex); + } + + private final CopyOnWriteArraySet mDictionaryChangeListeners = new CopyOnWriteArraySet<>(); + + public void addDictionaryChangeListener(final DictionaryChangeListener listener) { + mDictionaryChangeListeners.add(listener); + } + + public void removeDictionaryChangeListener(final DictionaryChangeListener listener) { + mDictionaryChangeListeners.remove(listener); + } + + /** Called after the native contents have changed, not when an update is merely queued. */ + protected final void notifyDictionaryChanged() { + notifyDictionaryChanged(true); + } + + private void notifyDictionaryChanged(final boolean affectsIndex) { + if (mClosed) return; + for (final DictionaryChangeListener listener : mDictionaryChangeListeners) { + listener.onDictionaryChanged(affectsIndex); + } + } /* A extension for a binary dictionary file. */ protected static final String DICT_FILE_EXTENSION = ".dict"; @@ -144,7 +175,6 @@ public ExpandableBinaryDictionary(final Context context, final String dictName, mDictFile = getDictFile(context, dictName, dictFile); mBinaryDictionary = null; mIsReloading = new AtomicBoolean(); - mNeedsToRecreate = false; mLock = new ReentrantReadWriteLock(); } @@ -159,8 +189,47 @@ public static String getDictName(final String name, final Locale locale, return dictFile != null ? dictFile.getName() : name + "." + locale.toLanguageTag(); } - private void asyncExecuteTaskWithWriteLock(final Runnable task) { - asyncExecuteTaskWithLock(mLock.writeLock(), task); + protected void asyncExecuteTaskWithWriteLock(final Runnable task) { + synchronized (mPendingWriteTasks) { + mPendingWriteTasks.add(task); + if (mPendingWriteTasks.size() != 1) return; + try { + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD) + .execute(this::drainWriteTasks); + } catch (final RejectedExecutionException e) { + mPendingWriteTasks.removeFirst(); + throw e; + } + } + } + + private void drainWriteTasks() { + Throwable failure = null; + while (true) { + final Runnable task; + synchronized (mPendingWriteTasks) { + task = mPendingWriteTasks.getFirst(); + } + mLock.writeLock().lock(); + try { + task.run(); + } catch (final RuntimeException | Error e) { + Log.e(TAG, "Dictionary mutation failed: " + mDictName, e); + if (failure == null) failure = e; + } finally { + mLock.writeLock().unlock(); + } + final boolean hasMore; + synchronized (mPendingWriteTasks) { + mPendingWriteTasks.removeFirst(); + hasMore = !mPendingWriteTasks.isEmpty(); + } + if (!hasMore) break; + } + // Accepted operations must still complete if an earlier one fails. Preserve its failure + // after draining rather than leaving later operations stuck behind an abandoned owner. + if (failure instanceof RuntimeException) throw (RuntimeException) failure; + if (failure instanceof Error) throw (Error) failure; } private static void asyncExecuteTaskWithLock(final Lock lock, final Runnable task) { @@ -206,9 +275,14 @@ void closeBinaryDictionary() { */ @Override public void close() { + mClosed = true; asyncExecuteTaskWithWriteLock(this::closeBinaryDictionary); } + protected final boolean isClosed() { + return mClosed; + } + protected Map getHeaderAttributeMap() { HashMap attributeMap = new HashMap<>(); attributeMap.put(DictionaryHeader.DICTIONARY_ID_KEY, mDictName); @@ -245,6 +319,7 @@ public void clear() { asyncExecuteTaskWithWriteLock(() -> { removeBinaryDictionaryLocked(); createOnMemoryBinaryDictionaryLocked(); + notifyDictionaryChanged(); }); } @@ -266,7 +341,8 @@ protected void runGCIfRequiredLocked(final boolean mindsBlockByGC) { } } - private void updateDictionaryWithWriteLock(@NonNull final Runnable updateTask) { + private void updateDictionaryWithWriteLock(@NonNull final Runnable updateTask, + final boolean affectsIndex) { reloadDictionaryIfRequired(); asyncExecuteTaskWithWriteLock(() -> { if (getBinaryDictionary() == null) { @@ -274,6 +350,7 @@ private void updateDictionaryWithWriteLock(@NonNull final Runnable updateTask) { } runGCIfRequiredLocked(true /* mindsBlockByGC */); updateTask.run(); + notifyDictionaryChanged(affectsIndex); }); } @@ -285,7 +362,7 @@ public void addUnigramEntry(final String word, final int frequency, final String shortcutTarget, final int shortcutFreq, final boolean isNotAWord, final boolean isPossiblyOffensive, final int timestamp) { updateDictionaryWithWriteLock(() -> addUnigramLocked(word, frequency, shortcutTarget, - shortcutFreq, isNotAWord, isPossiblyOffensive, timestamp)); + shortcutFreq, isNotAWord, isPossiblyOffensive, timestamp), true); } protected void addUnigramLocked(final String word, final int frequency, @@ -312,6 +389,8 @@ public void removeUnigramEntryDynamically(final String word) { if (DEBUG) { Log.i(TAG, "Cannot remove unigram entry: " + word); } + } else { + notifyDictionaryChanged(); } }); } @@ -359,7 +438,7 @@ public void updateEntriesForWord(@NonNull final NgramContext ngramContext, + " context: " + ngramContext); } } - }); + }, !TYPE_USER_HISTORY.equals(mDictType)); } @Override @@ -567,15 +646,19 @@ void createNewDictionaryLocked() { * */ protected void setNeedsToRecreate() { - mNeedsToRecreate = true; + mRecreateGeneration.incrementAndGet(); + } + + long getRecreateGeneration() { + return mRecreateGeneration.get(); } - void clearNeedsToRecreate() { - mNeedsToRecreate = false; + void clearNeedsToRecreate(final long loadedGeneration) { + mLoadedRecreateGeneration = loadedGeneration; } boolean isNeededToRecreate() { - return mNeedsToRecreate; + return mLoadedRecreateGeneration != mRecreateGeneration.get(); } /** @@ -589,6 +672,7 @@ boolean isNeededToRecreate() { * design. */ public final void reloadDictionaryIfRequired() { + if (mClosed) return; if (!isReloadRequired()) return; asyncReloadDictionary(); @@ -598,7 +682,7 @@ public final void reloadDictionaryIfRequired() { * Returns whether a dictionary reload is required. */ private boolean isReloadRequired() { - return mBinaryDictionary == null || mNeedsToRecreate; + return mBinaryDictionary == null || isNeededToRecreate(); } /** @@ -610,8 +694,11 @@ private void asyncReloadDictionary() { return; } final File dictFile = mDictFile; - asyncExecuteTaskWithWriteLock(() -> { + final Runnable reloadTask = () -> { + final long generation = getRecreateGeneration(); + boolean loaded = false; try { + if (mClosed) return; if (!dictFile.exists() || isNeededToRecreate()) { // If the dictionary file does not exist or contents have been updated, // generate a new one. @@ -630,11 +717,20 @@ && matchesExpectedBinaryDictFormatVersionForThisType( createNewDictionaryLocked(); } } - clearNeedsToRecreate(); + clearNeedsToRecreate(generation); + loaded = true; + if (!isNeededToRecreate()) notifyDictionaryChanged(); } finally { isReloading.set(false); + if (loaded && isNeededToRecreate()) reloadDictionaryIfRequired(); } - }); + }; + try { + asyncExecuteTaskWithWriteLock(reloadTask); + } catch (final RejectedExecutionException e) { + isReloading.set(false); + throw e; + } } /** diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java index 29c44ab6b..17c673fd7 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java @@ -7,6 +7,7 @@ package helium314.keyboard.latin.dictionary; import android.content.Context; +import android.content.ContentValues; import android.database.ContentObserver; import android.database.Cursor; import android.database.sqlite.SQLiteException; @@ -23,6 +24,7 @@ import java.io.File; import java.util.Arrays; import java.util.Locale; +import java.util.function.Consumer; /** * An expandable dictionary that stores the words in the user dictionary provider into a binary @@ -74,6 +76,7 @@ protected UserBinaryDictionary(final Context context, final Locale locale, @Override public void onChange(final boolean self, final Uri uri) { setNeedsToRecreate(); + reloadDictionaryIfRequired(); } }; context.getContentResolver().registerContentObserver(Words.CONTENT_URI, true, mObserver); @@ -85,6 +88,59 @@ public static UserBinaryDictionary getDictionary( return new UserBinaryDictionary(context, locale, true, dictFile, dictNamePrefix + NAME); } + /** Completes only after the provider entry is available in the native dictionary. */ + public void addWordToUserDictionary(final String word, final Consumer onComplete) { + asyncExecuteTaskWithWriteLock(() -> { + if (isClosed()) { + onComplete.accept(false); + return; + } + try { + final ContentValues values = new ContentValues(); + values.put(Words.WORD, word); + values.put(Words.FREQUENCY, HISTORICAL_DEFAULT_USER_DICTIONARY_FREQUENCY); + values.put(Words.LOCALE, TextUtils.isEmpty(mLocaleString) ? null : mLocaleString); + values.put(Words.APP_ID, 0); + if (mContext.getContentResolver().insert(Words.CONTENT_URI, values) == null) { + throw new IllegalStateException("User dictionary provider rejected insertion"); + } + setNeedsToRecreate(); + } catch (final Exception e) { + Log.w(TAG, "Failed to add word to user dictionary: " + word, e); + onComplete.accept(false); + return; + } + publishAddedWordLocked(word, onComplete); + }); + } + + private void publishAddedWordLocked(final String word, final Consumer onComplete) { + if (isClosed()) { + onComplete.accept(false); + return; + } + final long generation = getRecreateGeneration(); + boolean published = false; + try { + createNewDictionaryLocked(); + clearNeedsToRecreate(generation); + if (!isClosed() && isNeededToRecreate()) { + // A provider notification arrived after the query snapshot. Yield to queued work + // and read again; neither validity nor promotion completion may publish that snapshot. + asyncExecuteTaskWithWriteLock(() -> publishAddedWordLocked(word, onComplete)); + return; + } + published = !isClosed() && isInDictionaryLocked(word); + if (!published && !isClosed()) { + Log.w(TAG, "Inserted word was not loaded into the user dictionary: " + word); + } + } catch (final Exception e) { + Log.w(TAG, "Failed to load added word into user dictionary: " + word, e); + } + notifyDictionaryChanged(); + onComplete.accept(published); + } + @Override public synchronized void close() { if (mObserver != null) { diff --git a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java b/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java deleted file mode 100644 index a42f7c5a9..000000000 --- a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java +++ /dev/null @@ -1,660 +0,0 @@ -/* - * SwipeGestureEngine - gesture path matching for HeliBoard. - * - * Algorithm: arc-length resampling + L2 distance scoring. - * Each word in the dictionary is pre-mapped to a path of N_PTS evenly-spaced - * (x, y) points (normalized to keyboard dimensions). On gesture end the input - * stroke is resampled the same way and candidates are ranked by L2 distance - * with a small log-frequency bonus. - */ -package helium314.keyboard.latin.gesture; - -import android.content.Context; -import android.graphics.Rect; -import helium314.keyboard.keyboard.Key; -import helium314.keyboard.keyboard.Keyboard; -import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo; -import helium314.keyboard.latin.common.InputPointers; -import helium314.keyboard.latin.dictionary.Dictionary; -import helium314.keyboard.latin.utils.SuggestionResults; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class SwipeGestureEngine { - - private static final ExecutorService sSaveExecutor = Executors.newSingleThreadExecutor(); - - private static final int N_PTS = 16; - private static final float FREQ_WEIGHT = 0.05f; - - // ── Self-learning: boost words user actually confirmed via gesture ───────── - // ponytail: ConcurrentHashMap so corrections from any thread don't corrupt state - private static final ConcurrentHashMap sUserBoost = new ConcurrentHashMap<>(); - private static final ConcurrentHashMap sUserPaths = new ConcurrentHashMap<>(); - private static final int USER_BOOST_MAX = 50; // cap to avoid runaway inflation - private static final float[] sUserBoostCache = new float[USER_BOOST_MAX + 1]; - static { - for (int i = 0; i <= USER_BOOST_MAX; i++) { - sUserBoostCache[i] = (float) Math.log(i + 1) * 0.08f; - } - } - - private static File sUserDataFile = null; - - public static void initialize(Context context) { - if (sUserDataFile != null) return; - sUserDataFile = new File(context.getFilesDir(), "gesture_user_data.bin"); - loadUserData(); - } - - /** Call when user selects a gesture suggestion — bumps its score and saves their swipe path. */ - public static void recordAccepted(String word, InputPointers pointers, Keyboard keyboard, GestureIndex activeIndex) { - if (word == null || word.isEmpty()) return; - String key = word.toLowerCase(Locale.ROOT); - sUserBoost.merge(key, 1, (a, b) -> Math.min(a + b, USER_BOOST_MAX)); - - if (pointers != null && pointers.getPointerSize() >= 2 && keyboard != null) { - int n = pointers.getPointerSize(); - int[] xs = pointers.getXCoordinates(); - int[] ys = pointers.getYCoordinates(); - float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; - float[] rawFlat = new float[n * 2]; - for (int i = 0; i < n; i++) { - rawFlat[2 * i] = xs[i] / kw; - rawFlat[2 * i + 1] = ys[i] / kh; - } - float[] inputVec = resampleFlat(rawFlat, n, N_PTS); - sUserPaths.put(key, inputVec); - - // Update active index in-place if provided - if (activeIndex != null && !key.isEmpty()) { - char first = key.charAt(0); - List list = activeIndex.byFirst.get(first); - if (list != null) { - for (IndexEntry entry : list) { - if (getLowerCase(entry.word).equals(key)) { - float[] path = new float[N_PTS * 2]; - entry.unpackPath(path); - for (int i = 0; i < N_PTS * 2; i++) { - path[i] = path[i] * 0.3f + inputVec[i] * 0.7f; - } - entry.updatePath(path); - break; - } - } - } - } - } - - saveUserDataAsync(); - } - - private static void saveUserData() { - if (sUserDataFile == null) return; - try (java.io.DataOutputStream out = new java.io.DataOutputStream( - new java.io.BufferedOutputStream(new java.io.FileOutputStream(sUserDataFile)))) { - out.writeInt(1); // format version - - // Save boosts - out.writeInt(sUserBoost.size()); - for (Map.Entry entry : sUserBoost.entrySet()) { - out.writeUTF(entry.getKey()); - out.writeInt(entry.getValue()); - } - - // Save paths - out.writeInt(sUserPaths.size()); - for (Map.Entry entry : sUserPaths.entrySet()) { - out.writeUTF(entry.getKey()); - float[] path = entry.getValue(); - for (int i = 0; i < N_PTS * 2; i++) { - out.writeFloat(path[i]); - } - } - } catch (Exception e) { - android.util.Log.e("SwipeGestureEngine", "Error saving user data", e); - } - } - - private static void saveUserDataAsync() { - sSaveExecutor.execute(() -> { - synchronized (SwipeGestureEngine.class) { - saveUserData(); - } - }); - } - - private static void loadUserData() { - if (sUserDataFile == null || !sUserDataFile.exists()) return; - synchronized (SwipeGestureEngine.class) { - try (java.io.DataInputStream in = new java.io.DataInputStream( - new java.io.BufferedInputStream(new java.io.FileInputStream(sUserDataFile)))) { - int version = in.readInt(); - if (version != 1) return; - - sUserBoost.clear(); - int numBoosts = in.readInt(); - for (int i = 0; i < numBoosts; i++) { - String key = in.readUTF(); - int count = in.readInt(); - sUserBoost.put(key, count); - } - - sUserPaths.clear(); - int numPaths = in.readInt(); - for (int i = 0; i < numPaths; i++) { - String key = in.readUTF(); - float[] path = new float[N_PTS * 2]; - for (int j = 0; j < N_PTS * 2; j++) { - path[j] = in.readFloat(); - } - sUserPaths.put(key, path); - } - } catch (Exception e) { - android.util.Log.e("SwipeGestureEngine", "Error loading user data", e); - } - } - } - - // ── Precomputed index ───────────────────────────────────────────────────── - - private static String getLowerCase(String s) { - int len = s.length(); - for (int i = 0; i < len; i++) { - char c = s.charAt(i); - if (c >= 'A' && c <= 'Z') { - return s.toLowerCase(Locale.ROOT); - } - } - return s; - } - - private static long pack8Bytes(float[] pts, int startIndex) { - long value = 0; - for (int i = 0; i < 8; i++) { - float f = pts[startIndex + i]; - if (f < 0f) f = 0f; - else if (f > 1f) f = 1f; - int b = Math.round(f * 255f) & 0xFF; - value |= ((long) b) << (i * 8); - } - return value; - } - - private static void unpack8Bytes(long value, float[] out, int startIndex) { - for (int i = 0; i < 8; i++) { - int b = (int) ((value >>> (i * 8)) & 0xFF); - out[startIndex + i] = b / 255f; - } - } - - public static class IndexEntry { - public final String word; - public final int frequency; - // ponytail: cache path length and freq bonus to avoid recomputing every ranking call - public float pathLen; - public final float freqBonus; - private long path0; - private long path1; - private long path2; - private long path3; - - IndexEntry(String word, float[] path, int frequency) { - this.word = word; - this.frequency = frequency; - this.freqBonus = (frequency > 0) ? (float)(Math.log(frequency + 1) * FREQ_WEIGHT) : 0f; - - String lk = getLowerCase(word); - float[] blended = path; - float[] userPath = sUserPaths.get(lk); - if (userPath != null && userPath.length == N_PTS * 2) { - blended = new float[N_PTS * 2]; - for (int i = 0; i < N_PTS * 2; i++) { - blended[i] = path[i] * 0.3f + userPath[i] * 0.7f; - } - } - updatePath(blended); - } - - public void updatePath(float[] newPath) { - this.path0 = pack8Bytes(newPath, 0); - this.path1 = pack8Bytes(newPath, 8); - this.path2 = pack8Bytes(newPath, 16); - this.path3 = pack8Bytes(newPath, 24); - this.pathLen = pathLength(newPath); - } - - public void unpackPath(float[] out) { - unpack8Bytes(path0, out, 0); - unpack8Bytes(path1, out, 8); - unpack8Bytes(path2, out, 16); - unpack8Bytes(path3, out, 24); - } - } - - public static class GestureIndex { - public final Map> byFirst; - // ponytail: store charToPos in index so rankByIndex doesn't rebuild it every call - public final Map charToPos; - GestureIndex(Map> byFirst, Map charToPos) { - this.byFirst = byFirst; - this.charToPos = charToPos; - } - } - - public static volatile boolean isCancelled = false; - - public static void cancelIndexing() { - isCancelled = true; - } - - public static GestureIndex buildIndex(helium314.keyboard.latin.DictionaryFacilitator facilitator, Keyboard keyboard) { - isCancelled = false; - Map charToPos = buildCharToPos(keyboard); - Map> byFirst = new HashMap<>(); - try { - facilitator.forEachMainDictionaryWord((raw, freqVal) -> { - if (isCancelled) return; - if (raw == null) return; - if (facilitator.isBlacklisted(raw)) return; - int freq = freqVal != null ? freqVal : 0; - // ponytail: apply user boost to freq so self-learned words rank higher immediately - String lk = getLowerCase(raw); - Integer boost = sUserBoost.get(lk); - if (boost != null) freq = Math.min(freq + boost * 5, 255); - if (freq < 12) return; - String word = lk; - if (word.isEmpty()) return; - char first = word.charAt(0); - if (!charToPos.containsKey(first)) return; - float[] path = wordPath(word, charToPos); - byFirst.computeIfAbsent(first, k -> new ArrayList<>()) - .add(new IndexEntry(word, path, freq)); - }); - for (Map.Entry> entry : byFirst.entrySet()) { - List list = entry.getValue(); - list.sort((a, b) -> Integer.compare(b.frequency, a.frequency)); - if (list.size() > 2000) { - entry.setValue(new ArrayList<>(list.subList(0, 2000))); - } - } - } catch (OutOfMemoryError e) { - android.util.Log.e("SwipeGestureEngine", "OOM building gesture index, using partial index", e); - System.gc(); - } - return new GestureIndex(byFirst, charToPos); - } - - public static int layoutFingerprint(Keyboard keyboard) { - Map map = buildCharToPos(keyboard); - Object[] values = new Object[map.size()]; - int idx = 0; - for (float[] p : map.values()) { - values[idx++] = p; - } - return Arrays.deepHashCode(values); - } - - // ── Public matching API ─────────────────────────────────────────────────── - - private static boolean isAsciiLetter(int code) { - return (code >= 'a' && code <= 'z') || (code >= 'A' && code <= 'Z'); - } - - // ponytail: use charToPos directly instead of iterating all keys on every gesture - private static List nearestLettersFromMap(float nx, float ny, Map charToPos) { - float minDist = Float.MAX_VALUE; - Map dists = new HashMap<>(); - for (Map.Entry entry : charToPos.entrySet()) { - float[] pos = entry.getValue(); - float cx = pos[0], cy = pos[1]; - float d = (nx - cx) * (nx - cx) + (ny - cy) * (ny - cy); - dists.put(entry.getKey(), d); - if (d < minDist) minDist = d; - } - List results = new ArrayList<>(4); - float threshold = minDist + 0.035f; - for (Map.Entry entry : dists.entrySet()) { - if (entry.getValue() <= threshold) results.add(entry.getKey()); - } - return results; - } - - // kept public for external callers (e.g. tests) - public static List nearestLetters(int x, int y, Keyboard keyboard) { - float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; - return nearestLettersFromMap(x / kw, y / kh, buildCharToPos(keyboard)); - } - - private static float sqDistanceToSegment(float px, float py, float ax, float ay, float bx, float by, float[] outT) { - float dx = bx - ax; - float dy = by - ay; - float segmentLenSq = dx * dx + dy * dy; - if (segmentLenSq < 1e-9f) { - outT[0] = 0f; - return (px - ax) * (px - ax) + (py - ay) * (py - ay); - } - float t = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq; - if (t < 0f) t = 0f; - else if (t > 1f) t = 1f; - outT[0] = t; - float closestX = ax + t * dx; - float closestY = ay + t * dy; - return (px - closestX) * (px - closestX) + (py - closestY) * (py - closestY); - } - - public static boolean isSequenceMatch(String word, float[] path, Map charToPos) { - int n = path.length / 2; - int segmentIdx = 0; - float prevT = -0.01f; - char lastChar = 0; - float[] outT = new float[1]; - for (int i = 0; i < word.length(); i++) { - char c = word.charAt(i); - if (c == lastChar) continue; - float[] target = charToPos.get(c); - if (target == null) continue; - boolean found = false; - while (segmentIdx < n - 1) { - float distSq = sqDistanceToSegment(target[0], target[1], - path[2 * segmentIdx], path[2 * segmentIdx + 1], - path[2 * (segmentIdx + 1)], path[2 * (segmentIdx + 1) + 1], outT); - if (distSq <= 0.05f) { - float t = outT[0]; - if (t > prevT) { - prevT = t; - found = true; - break; - } - } - segmentIdx++; - prevT = -0.01f; - } - if (!found) return false; - lastChar = c; - } - return true; - } - - public static SuggestionResults rankByIndex( - GestureIndex index, - InputPointers pointers, - Keyboard keyboard, - int maxResults, - java.util.Set predictionSet - ) { - int n = pointers.getPointerSize(); - SuggestionResults empty = new SuggestionResults(1, false, false); - if (n < 2 || index == null) return empty; - - int[] xs = pointers.getXCoordinates(); - int[] ys = pointers.getYCoordinates(); - float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; - - // ponytail: use charToPos from index — already built, no reallocation - Map charToPos = index.charToPos; - - List startLetters = nearestLettersFromMap(xs[0] / kw, ys[0] / kh, charToPos); - List endLetters = nearestLettersFromMap(xs[n-1] / kw, ys[n-1] / kh, charToPos); - - List candidates = new ArrayList<>(); - for (char first : startLetters) { - List list = index.byFirst.get(first); - if (list != null) candidates.addAll(list); - } - if (candidates.isEmpty()) return empty; - - // ponytail: build flat input path inline, no ArrayList allocation - float[] rawFlat = new float[n * 2]; - for (int i = 0; i < n; i++) { - rawFlat[2 * i] = xs[i] / kw; - rawFlat[2 * i + 1] = ys[i] / kh; - } - float[] inputVec = resampleFlat(rawFlat, n, N_PTS); - float inputLength = pathLength(inputVec); - - // Filter by last letter first; relax if empty - List filtered = new ArrayList<>(candidates.size()); - for (IndexEntry e : candidates) { - String lower = getLowerCase(e.word); - if (!lower.isEmpty() && endLetters.contains(lower.charAt(lower.length() - 1))) - filtered.add(e); - } - if (filtered.isEmpty()) filtered = candidates; - - int m = filtered.size(); - - // ponytail: parallel float[] + int[] sort avoids Integer boxing - float[] scores = new float[m]; - int[] order = new int[m]; - int count = 0; - float[] topScores = new float[maxResults]; - Arrays.fill(topScores, -Float.MAX_VALUE); - float threshold = -Float.MAX_VALUE; - - float[] candidatePath = new float[N_PTS * 2]; - for (int i = 0; i < m; i++) { - IndexEntry e = filtered.get(i); - String lower = getLowerCase(e.word); - boolean isPredicted = predictionSet != null && predictionSet.contains(lower); - float predBonus = isPredicted ? 0.15f : 0f; - float lenPenalty = -Math.abs(inputLength - e.pathLen) * 0.4f; - Integer ub = sUserBoost.get(lower); - float userBonus = ub != null ? sUserBoostCache[ub] : 0f; - - float bonuses = e.freqBonus + predBonus + lenPenalty + userBonus; - if (bonuses < threshold) { - continue; - } - - boolean seqMatch = isSequenceMatch(lower, inputVec, charToPos); - float seqPenalty = seqMatch ? 0f : -0.4f; - float scoreWithSeq = bonuses + seqPenalty; - if (scoreWithSeq < threshold) { - continue; - } - - e.unpackPath(candidatePath); - float maxL2 = (threshold == -Float.MAX_VALUE) ? Float.MAX_VALUE : (scoreWithSeq - threshold); - float distance = l2(inputVec, candidatePath, maxL2); - float score = -distance + scoreWithSeq; - - scores[count] = score; - order[count] = i; - count++; - - if (score > threshold) { - threshold = updateThreshold(topScores, score); - } - } - - if (count == 0) return empty; - - // ponytail: primitive int sort with insertion sort for small N (fast for <500 items) - for (int i = 1; i < count; i++) { - int key = order[i]; - float ks = scores[i]; - int j = i - 1; - while (j >= 0 && scores[j] < ks) { - scores[j + 1] = scores[j]; - order[j + 1] = order[j]; - j--; - } - scores[j + 1] = ks; - order[j + 1] = key; - } - - int take = Math.min(maxResults, count); - SuggestionResults result = new SuggestionResults(take, false, false); - int baseScore = 1_000_000; - for (int rank = 0; rank < take; rank++) { - IndexEntry e = filtered.get(order[rank]); - result.add(new SuggestedWordInfo( - e.word, "", - baseScore - rank * 1000, - SuggestedWordInfo.KIND_CORRECTION, - Dictionary.DICTIONARY_USER_TYPED, - SuggestedWordInfo.NOT_AN_INDEX, - SuggestedWordInfo.NOT_A_CONFIDENCE - )); - } - return result; - } - - // ── Internals ───────────────────────────────────────────────────────────── - - private static float pathLength(float[] path) { - float len = 0; - int n = path.length / 2; - for (int i = 0; i < n - 1; i++) { - float dx = path[2 * (i + 1)] - path[2 * i]; - float dy = path[2 * (i + 1) + 1] - path[2 * i + 1]; - len += (float) Math.sqrt(dx * dx + dy * dy); - } - return len; - } - - static Map buildCharToPos(Keyboard keyboard) { - Map map = new HashMap<>(); - float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; - for (Key key : keyboard.getSortedKeys()) { - int code = key.getCode(); - if (code <= 0) continue; - char c = Character.toLowerCase((char) code); - Rect hitBox = key.getHitBox(); - map.put(c, new float[]{hitBox.exactCenterX() / kw, hitBox.exactCenterY() / kh}); - } - return map; - } - - static float[] wordPath(String word, Map charToPos) { - float[] pts = new float[word.length() * 2]; - int count = 0; - float lastX = -1f, lastY = -1f; - for (int i = 0; i < word.length(); i++) { - char c = word.charAt(i); - float[] p = charToPos.get(c); - if (p == null) continue; - if (count == 0 || p[0] != lastX || p[1] != lastY) { - pts[2 * count] = p[0]; - pts[2 * count + 1] = p[1]; - lastX = p[0]; lastY = p[1]; - count++; - } - } - return resampleFlat(pts, count, N_PTS); - } - - static float[] resampleFlat(float[] pts, int numPts, int n) { - if (numPts == 0) return new float[n * 2]; - if (numPts == 1) { - float[] r = new float[n * 2]; - float x = pts[0], y = pts[1]; - for (int i = 0; i < n; i++) { r[2*i] = x; r[2*i+1] = y; } - return r; - } - float[] cum = new float[numPts]; - for (int i = 1; i < numPts; i++) { - float dx = pts[2 * i] - pts[2 * (i - 1)]; - float dy = pts[2 * i + 1] - pts[2 * (i - 1) + 1]; - cum[i] = cum[i-1] + (float) Math.sqrt(dx*dx + dy*dy); - } - float total = cum[numPts-1]; - if (total < 1e-9f) { - float[] r = new float[n * 2]; - float x = pts[0], y = pts[1]; - for (int i = 0; i < n; i++) { r[2*i] = x; r[2*i+1] = y; } - return r; - } - float[] result = new float[n * 2]; - int seg = 0; - for (int i = 0; i < n; i++) { - float t = total * i / (n - 1); - while (seg < numPts - 2 && cum[seg + 1] < t) seg++; - float segLen = cum[seg+1] - cum[seg]; - float alpha = (segLen > 1e-9f) ? (t - cum[seg]) / segLen : 0f; - result[2*i] = pts[2 * seg] + alpha * (pts[2 * (seg + 1)] - pts[2 * seg]); - result[2*i+1] = pts[2 * seg + 1] + alpha * (pts[2 * (seg + 1) + 1] - pts[2 * seg + 1]); - } - return result; - } - - // ponytail: kept for compat, delegates to resampleFlat - static float[] resample(List pts, int n) { - float[] flat = new float[pts.size() * 2]; - for (int i = 0; i < pts.size(); i++) { - flat[2*i] = pts.get(i)[0]; - flat[2*i+1] = pts.get(i)[1]; - } - return resampleFlat(flat, pts.size(), n); - } - - private static float l2(float[] a, float[] b, float maxL2) { - float s = 0; - int n = a.length / 2; - float limitSq = maxL2 * maxL2; - for (int i = 0; i < n; i++) { - float dx = a[2 * i] - b[2 * i]; - float dy = a[2 * i + 1] - b[2 * i + 1]; - float distSq = dx * dx + dy * dy; - // ponytail: weight endpoints twice — more precisely typed - if (i == 0 || i == n - 1) s += distSq * 2.0f; - else s += distSq; - if (s > limitSq) return Float.MAX_VALUE; - } - return (float) Math.sqrt(s); - } - - private static float updateThreshold(float[] topScores, float newScore) { - int minIdx = 0; - for (int i = 1; i < topScores.length; i++) { - if (topScores[i] < topScores[minIdx]) minIdx = i; - } - if (newScore > topScores[minIdx]) { - topScores[minIdx] = newScore; - } - float min = topScores[0]; - for (int i = 1; i < topScores.length; i++) { - if (topScores[i] < min) min = topScores[i]; - } - return min; - } - - public static boolean hasLoopAtEnd(InputPointers pointers, Keyboard keyboard) { - int n = pointers.getPointerSize(); - if (n < 6) return false; - int[] xs = pointers.getXCoordinates(); - int[] ys = pointers.getYCoordinates(); - - // Look at the last min(n/2, 10) points - int pointsToCheck = Math.min(n / 2, 10); - if (pointsToCheck < 4) pointsToCheck = 4; - int startIdx = n - pointsToCheck; - - float pathLen = 0f; - for (int i = startIdx; i < n - 1; i++) { - float dx = xs[i+1] - xs[i]; - float dy = ys[i+1] - ys[i]; - pathLen += (float) Math.sqrt(dx * dx + dy * dy); - } - - float startEndX = xs[n - 1] - xs[startIdx]; - float startEndY = ys[n - 1] - ys[startIdx]; - float displacement = (float) Math.sqrt(startEndX * startEndX + startEndY * startEndY); - - float kw = keyboard.mOccupiedWidth; - // Make sure the loop is physically large enough to be a deliberate loop, not finger jitter - if (pathLen < kw * 0.02f) return false; - - return pathLen > 2.0f * displacement; - } -} 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 c6f57181f..e17781952 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -41,6 +41,7 @@ import helium314.keyboard.latin.LastComposedWord; import helium314.keyboard.latin.LatinIME; import helium314.keyboard.latin.NgramContext; +import helium314.keyboard.latin.R; import helium314.keyboard.latin.RichInputConnection; import helium314.keyboard.latin.SingleDictionaryFacilitator; import helium314.keyboard.latin.Suggest; @@ -102,11 +103,6 @@ public final class InputLogic { private int mSpaceState; // Never null private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); - // #14 spacing-policy signals — recomputed every keystroke from the suggestion results at zero - // extra native cost (see computeSpacingSignals / setSuggestedWords). Consumed by the upcoming - // signal-driven grace + two-gate Assisted-tier logic. - private boolean mSpacingComplete; // typed word is a real dictionary word - private float mSpacingPrefixRichScore; // fraction of candidates that are completions [0..1] private final Suggest mSuggest; private final DictionaryFacilitator mDictionaryFacilitator; private SingleDictionaryFacilitator mEmojiDictionaryFacilitator; @@ -119,12 +115,8 @@ public final class InputLogic { private int mDeleteCount; private long mLastKeyTime; - // Two-thumb typing (#1.4): when {@code mGestureTapPromotionMs > 0} AND the user starts a - // gesture within that window of their last letter tap, the gesture extends the existing - // composing word instead of replacing it (the manual-spacing-extend path). Flag is set in - // {@link #onStartBatchInput} and consumed at the end of {@link #onUpdateTailBatchInputCompleted}. - // This lets us make the "extend or not" decision based on the timing AT THE MOMENT OF - // GESTURE-START, not gesture-end (so a long gesture doesn't lose the promotion). + // Capture whether the gesture extends the current composition at gesture start, + // so a long gesture does not lose that decision before its result arrives. private boolean mGestureExtendsByTapPromotion; // Snapshot of {@code keyboardSwitcher.getKeyboardShiftMode()} captured at the start of @@ -155,8 +147,7 @@ public final class InputLogic { // an autospace because the timer fires once the user pauses. // // Visual: while a commit is pending, the spacebar shows a countdown progress bar - // (the {@link MainKeyboardView#setCombiningMode} call) — replaces the older - // PREF_AUTOSPACE_VISUAL_HINT flash, which was decoupled from the state that caused it. + // (the {@link MainKeyboardView#setCombiningMode} call). // // All access on the main thread (touch events + Handler posts to main looper). private final Handler mCombiningHandler = new Handler(Looper.getMainLooper()); @@ -921,9 +912,6 @@ public void onCancelBatchInput(final LatinIME.UIHandler handler) { // Combining mode: cancelled gesture wipes the would-be-fragment from the composing // word — drop the timer so we don't fire an autospace based on stale state. cancelCombiningMode(); - // Drop any seed codepoint stashed by PointerTracker so the next gesture doesn't - // strip its first letter against a stale seed. - helium314.keyboard.keyboard.PointerTracker.consumeGestureSeedCodepoint(); // Two-thumb typing: a cancelled gesture never reaches onUpdateTailBatchInputCompleted, // which is the only routine site that clears the merged-trail extend-base. Drop it here // so this cancelled gesture's trail can't leak into the next one. @@ -1170,16 +1158,6 @@ private void cancelCombiningTimerOnly() { } } - /** Combining-mode seeding helper: last codepoint of the current composing word, or 0 - * if no composing word. Currently unused but kept available for future seeding work. */ - @SuppressWarnings("unused") - private int lastCodepointOfTypedWord() { - if (!mWordComposer.isComposingWord()) return 0; - final String w = mWordComposer.getTypedWord(); - if (w.isEmpty()) return 0; - return w.codePointBefore(w.length()); - } - /** Public accessor so PointerTracker / KeyboardActionListenerImpl can ask "are we extending right now?". */ public boolean isInCombiningMode() { return mInCombiningMode; @@ -1426,9 +1404,6 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { mWordComposer.setAutoCorrection(suggestedWordInfo); } mSuggestedWords = suggestedWords; - final SpacingSignals spacingSignals = computeSpacingSignals(suggestedWords); - mSpacingComplete = spacingSignals.complete; - mSpacingPrefixRichScore = spacingSignals.prefixRichScore; final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect; // Put a blue underline to a word in TextView which will be auto-corrected. @@ -1446,43 +1421,6 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { } } - /** - * #14 spacing-policy signals derived from the current suggestion results, computed every - * keystroke at zero extra native cost. - *

      - *
    • {@code complete} — the typed word is a real dictionary word (valid AND not just - * user-typed). A confident "this is a finished word".
    • - *
    • {@code prefixRichScore} — fraction of candidates that are completions (longer words - * sharing this stem), in [0..1]. High = lots left to extend to (keep the word open); - * low = little left (safe to auto-commit).
    • - *
    - * Static + pure so it can be unit-tested without a live InputLogic. - */ - static final class SpacingSignals { - final boolean complete; - final float prefixRichScore; - SpacingSignals(final boolean complete, final float prefixRichScore) { - this.complete = complete; - this.prefixRichScore = prefixRichScore; - } - } - - static SpacingSignals computeSpacingSignals(final SuggestedWords suggestedWords) { - final int n = suggestedWords.size(); - if (n == 0) return new SpacingSignals(false, 0f); - final SuggestedWordInfo typed = suggestedWords.mTypedWordInfo; - final boolean complete = suggestedWords.mTypedWordValid - && typed != null && typed.mSourceDict != null - && !Dictionary.TYPE_USER_TYPED.equals(typed.mSourceDict.mDictType); - int completions = 0; - for (int i = 0; i < n; i++) { - if (suggestedWords.getInfo(i).getKind() == SuggestedWordInfo.KIND_COMPLETION) { - completions++; - } - } - return new SpacingSignals(complete, (float) completions / n); - } - /** * Handle a consumed event. *

    @@ -1537,10 +1475,18 @@ private void handleClipboardPaste() { } } - // Store last text before proofreading for undo functionality - private String mTextBeforeProofread = null; + private long mAiRequestId; + + private helium314.keyboard.latin.utils.AiEditorRequest prepareAiRequest(final boolean append) { + final var request = helium314.keyboard.latin.utils.AiEditorRequest.prepare(mLatinIME, mConnection, append); + if (request == null) { + KeyboardSwitcher.getInstance().showToast(mLatinIME.getString(R.string.ai_editor_unavailable), true); + } + return request; + } private void handleProofread() { + final long requestId = ++mAiRequestId; Log.i(TAG, "handleProofread() called"); // If an operation is in progress, cancel it @@ -1549,47 +1495,10 @@ private void handleProofread() { helium314.keyboard.latin.utils.ProofreadHelper.cancelCurrentOperation(); return; } - String textToProofread; - final boolean hasSelection = mConnection.hasSelection(); - - if (hasSelection) { - final CharSequence selectedText = mConnection.getSelectedText(0); - textToProofread = selectedText != null ? selectedText.toString() : ""; - Log.i(TAG, "Proofreading selected text: " + textToProofread.length() + " chars"); - } else { - // Get entire text field content FIRST to ensure we capture everything relative - // to cursor - final int maxChars = 60000; - CharSequence textBefore = null; - CharSequence textAfter = null; - try { - textBefore = mConnection.getTextBeforeCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text before cursor: " + e); - } - - try { - textAfter = mConnection.getTextAfterCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text after cursor: " + e); - // Try with smaller amount if large amount failed - try { - textAfter = mConnection.getTextAfterCursor(2048, 0); - } catch (Exception e2) { - Log.e(TAG, "Failed to get text after cursor (retry): " + e2); - } - } - - final String before = textBefore != null ? textBefore.toString() : ""; - final String after = textAfter != null ? textAfter.toString() : ""; - textToProofread = before + after; - - // Select all text NOW so user sees what will be proofread - mConnection.selectAll(); - } - - // Store original text for undo (via standard undo mechanism) - mTextBeforeProofread = textToProofread; + final var request = prepareAiRequest(false); + if (request == null) return; + final String textToProofread = request.getOriginalText(); + final boolean hasSelection = request.getHasSelection(); // Use the Kotlin helper for async proofreading helium314.keyboard.latin.utils.ProofreadHelper.proofreadAsync( @@ -1599,9 +1508,19 @@ private void handleProofread() { new helium314.keyboard.latin.utils.ProofreadHelper.ProofreadCallback() { @Override public void onSuccess(String proofreadText) { - - if (proofreadText != null && !proofreadText.equals(mTextBeforeProofread)) { - + if (requestId != mAiRequestId || !request.isCurrent()) return; + + if (proofreadText != null && !proofreadText.isEmpty() && !proofreadText.equals(textToProofread)) { + // Truncation safeguard: if original input was substantial (> 20 chars) and proofreadText is less than 30% of original text length, abort to prevent accidental data loss + if (textToProofread.length() > 20 && proofreadText.length() < textToProofread.length() * 0.3) { + Log.w(TAG, "Proofread result suspiciously short; replacement aborted"); + helium314.keyboard.keyboard.KeyboardSwitcher.getInstance().showToast("Proofread output truncated by model; replacement aborted.", false); + if (!hasSelection) { + int len = textToProofread.length(); + mConnection.setSelection(len, len); + } + return; + } // Text should already be selected (either user selection or selectAll before // API call) // Just commit the new text to replace selection @@ -1611,7 +1530,7 @@ public void onSuccess(String proofreadText) { // Deselect the text since no changes were made if (!hasSelection) { - int len = mTextBeforeProofread != null ? mTextBeforeProofread.length() : 0; + int len = textToProofread.length(); mConnection.setSelection(len, len); } } @@ -1619,57 +1538,19 @@ public void onSuccess(String proofreadText) { @Override public void onError(String errorMessage) { + if (requestId != mAiRequestId || !request.isCurrent()) return; // Error toast is already shown by ProofreadHelper - Log.e(TAG, "Proofreading error: " + errorMessage); + Log.e(TAG, "Proofreading failed"); } }); } - // Store last text before translation for undo functionality - private String mTextBeforeTranslate = null; - private void handleTranslate() { - - // Get selected text or entire text field content - String textToTranslate; - final boolean hasSelection = mConnection.hasSelection(); - - if (hasSelection) { - final CharSequence selectedText = mConnection.getSelectedText(0); - textToTranslate = selectedText != null ? selectedText.toString() : ""; - - } else { - // Get entire text field content FIRST - final int maxChars = 60000; - CharSequence textBefore = null; - CharSequence textAfter = null; - try { - textBefore = mConnection.getTextBeforeCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text before cursor: " + e); - } - - try { - textAfter = mConnection.getTextAfterCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text after cursor: " + e); - try { - textAfter = mConnection.getTextAfterCursor(2048, 0); - } catch (Exception e2) { - Log.e(TAG, "Failed to get text after cursor (retry): " + e2); - } - } - - final String before = textBefore != null ? textBefore.toString() : ""; - final String after = textAfter != null ? textAfter.toString() : ""; - textToTranslate = before + after; - - // Select all text NOW - mConnection.selectAll(); - } - - // Store original text for undo - mTextBeforeTranslate = textToTranslate; + final long requestId = ++mAiRequestId; + final var request = prepareAiRequest(false); + if (request == null) return; + final String textToTranslate = request.getOriginalText(); + final boolean hasSelection = request.getHasSelection(); // Use the Kotlin helper for async translation helium314.keyboard.latin.utils.ProofreadHelper.translateAsync( @@ -1679,14 +1560,24 @@ private void handleTranslate() { new helium314.keyboard.latin.utils.ProofreadHelper.ProofreadCallback() { @Override public void onSuccess(String translatedText) { - - if (translatedText != null && !translatedText.equals(mTextBeforeTranslate)) { - + if (requestId != mAiRequestId || !request.isCurrent()) return; + + if (translatedText != null && !translatedText.equals(textToTranslate)) { + // Truncation safeguard: if original input was substantial (> 20 chars) and translatedText is less than 30% of original text length, abort to prevent accidental data loss + if (textToTranslate.length() > 20 && translatedText.length() < textToTranslate.length() * 0.3) { + Log.w(TAG, "Translation result suspiciously short; replacement aborted"); + helium314.keyboard.keyboard.KeyboardSwitcher.getInstance().showToast("Translation output truncated by model; replacement aborted.", false); + if (!hasSelection) { + int len = textToTranslate.length(); + mConnection.setSelection(len, len); + } + return; + } mConnection.commitText(translatedText, 1); } else { if (!hasSelection) { - int len = mTextBeforeTranslate != null ? mTextBeforeTranslate.length() : 0; + int len = textToTranslate.length(); mConnection.setSelection(len, len); } } @@ -1694,7 +1585,8 @@ public void onSuccess(String translatedText) { @Override public void onError(String errorMessage) { - Log.e(TAG, "Translation error: " + errorMessage); + if (requestId != mAiRequestId || !request.isCurrent()) return; + Log.e(TAG, "Translation failed"); } }); } @@ -2758,7 +2650,27 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu StatsUtils.onBackspacePressed(1); } if (mWordComposer.isComposingWord()) { - setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1); + final String typedWord = mWordComposer.getTypedWord(); + setComposingTextInternal(getTextWithUnderline(typedWord), 1); + if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME) + && helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) { + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final helium314.keyboard.latin.utils.TextExpanderUtils.ExpandedResult result = + helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWordForTyped(typedWord, textBefore.toString(), mLatinIME); + if (result != null) { + if (mJustRevertedExpandedShortcut == null + || !result.getMatchedString().equalsIgnoreCase(mJustRevertedExpandedShortcut)) { + if (result.getPrefixLength() > 0) { + mConnection.commitText("", 1); + mConnection.deleteTextBeforeCursor(result.getPrefixLength()); + } + commitExpandedText(result.getMatchedString(), result.getExpandedText()); + resetComposingState(true); + } + } + } + } } else { if (wasBatchMode || wholeWordDeleted) { // Composing word gone (whole-word/batch delete): clear the composing span in @@ -3738,11 +3650,14 @@ public int getCurrentAutoCapsState(final SettingsValues settingsValues) { || InputTypeUtils.isVisiblePasswordInputType(inputType)) { return Constants.TextUtils.CAP_MODE_OFF; } - inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; } - if (settingsValues.mForceAutoCaps && !InputTypeUtils.isPasswordInputType(inputType) - && !InputTypeUtils.isVisiblePasswordInputType(inputType)) { - inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + if (!InputTypeUtils.isAnyPasswordInputType(inputType) + && !InputTypeUtils.isUriOrEmailType(inputType) + && (inputType & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT) { + if (settingsValues.mForceAutoCaps + || (inputType & (InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS | InputType.TYPE_TEXT_FLAG_CAP_WORDS | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)) == 0) { + inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } } // Warning: this depends on mSpaceState, which may not be the most current // value. If @@ -3816,20 +3731,15 @@ private void performEditorAction(final int actionId, final SettingsValues settin final LatinIME.UIHandler handler) { clearOneShotSpaceActionAndNotifyIfChanged(); if (mWordComposer.isComposingWord()) { - final SuggestedWordInfo autoCorrection = mWordComposer.getAutoCorrectionOrNull(); final String typedWord = mWordComposer.getTypedWord(); - if (autoCorrection != null && !typedWord.equals(autoCorrection.mWord)) { - commitCurrentAutoCorrection(settingsValues, LastComposedWord.NOT_A_SEPARATOR, handler); - } else { - final NgramContext ngramContext = mConnection.getNgramContextFromNthPreviousWord( - settingsValues.mSpacingAndPunctuations, 1); - performAdditionToUserHistoryDictionary(settingsValues, typedWord, ngramContext); - mLastComposedWord = mWordComposer.commitWord( - LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, typedWord, - LastComposedWord.NOT_A_SEPARATOR, ngramContext); - mConnection.finishComposingText(); - StatsUtils.onWordCommitUserTyped(typedWord, mWordComposer.isBatchMode()); - } + final NgramContext ngramContext = mConnection.getNgramContextFromNthPreviousWord( + settingsValues.mSpacingAndPunctuations, 1); + performAdditionToUserHistoryDictionary(settingsValues, typedWord, ngramContext); + mLastComposedWord = mWordComposer.commitWord( + LastComposedWord.COMMIT_TYPE_USER_TYPED_WORD, typedWord, + LastComposedWord.NOT_A_SEPARATOR, ngramContext); + mConnection.finishComposingText(); + StatsUtils.onWordCommitUserTyped(typedWord, mWordComposer.isBatchMode()); } mConnection.performEditorAction(actionId); } @@ -4164,32 +4074,8 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, } } if (TextUtils.isEmpty(batchInputText)) { - // Still need to clear the seed slot so it doesn't leak into the next gesture. - helium314.keyboard.keyboard.PointerTracker.consumeGestureSeedCodepoint(); return; } - // Combining-mode seeding: if PointerTracker seeded this gesture with a prior tap's - // coords, the recognizer typically includes that letter as the first char of the - // result. We strip it (case-insensitive) so the existing concat below doesn't - // double-count it. See PointerTracker for the full rationale. - // - // Multi-part word composition (#1.6): the top suggestion isn't always the seeded - // continuation — e.g. swiping "nology" after "tech" might recognize as "biology" - // because the second swipe's path can match both. When a seed is set, prefer any - // suggestion (in score order) whose first letter matches the seed; only fall back - // to the top suggestion if none match. - // Combining-mode seeding: if PointerTracker seeded this gesture with a prior tap's - // coords, the recognizer typically includes that letter as the first char of the - // result. We strip it (case-insensitive) so the existing concat below doesn't - // double-count it. See PointerTracker for the full rationale. - final int seedCp = helium314.keyboard.keyboard.PointerTracker.consumeGestureSeedCodepoint(); - if (seedCp > 0 && batchInputText.length() > 0) { - final int firstCp = batchInputText.codePointAt(0); - if (Character.toLowerCase(firstCp) == Character.toLowerCase(seedCp)) { - batchInputText = batchInputText.substring(Character.charCount(firstCp)); - } - } - if (batchInputText.isEmpty()) return; mCombiningWordHasGestureFragment = true; mConnection.beginBatchEdit(); // Two-thumb typing (#1.1 + #1.4): when either manual spacing OR tap-promotion-extend @@ -4230,8 +4116,7 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, final String prevTypedWord = (extendExistingCompose && !usedMergedTrail) ? mWordComposer.getTypedWord() : ""; if (settingsValues.mGestureDebugDrawPoints) { - Log.d(TAG, "batch merge seed=" + seedCp - + " extendExisting=" + extendExistingCompose + Log.d(TAG, "batch merge extendExisting=" + extendExistingCompose + " combiningExtends=" + combiningExtendsSwipe + " usedMergedTrail=" + usedMergedTrail + " prevTyped='" + prevTypedWord + "'" @@ -4936,6 +4821,7 @@ private void closeEmojiDictionary() { } private void handleCustomAIKey(int index) { + final long requestId = ++mAiRequestId; final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils .getSharedPreferences(mLatinIME); String prompt = prefs.getString("pref_custom_ai_prompt_" + index, ""); @@ -5001,96 +4887,24 @@ private void handleCustomAIKey(int index) { // it. prompt = prompt + systemInstruction; - // Get selected text or entire text field content - String textToProcess; - final boolean hasSelection = mConnection.hasSelection(); - - if (hasSelection) { - final CharSequence selectedText = mConnection.getSelectedText(0); - textToProcess = selectedText != null ? selectedText.toString() : ""; - // If appending on a selection, we unselect (move cursor to end of selection) so - // we don't overwrite? - // User requested "generate wish after 'see you'", which implies keeping 'see - // you'. - if (shouldAppend) { - mConnection.setSelection(mConnection.getExpectedSelectionEnd(), mConnection.getExpectedSelectionEnd()); - // Insert a separator? - // For now, let's just append. The AI result might need leading space/newline. - // We can inject a newline into the prompt request implicitly or ask the AI to - // include it? - // Better: "Output result. Start with a newline if appropriate." - simplistic - // but maybe enough. - } - } else { - // Get entire text field content FIRST - final int maxChars = 60000; - CharSequence textBefore = null; - CharSequence textAfter = null; - try { - textBefore = mConnection.getTextBeforeCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text before cursor: " + e); - } - - try { - textAfter = mConnection.getTextAfterCursor(maxChars, 0); - } catch (Exception e) { - Log.e(TAG, "Failed to get text after cursor: " + e); - try { - textAfter = mConnection.getTextAfterCursor(2048, 0); - } catch (Exception e2) { - Log.e(TAG, "Failed to get text after cursor (retry): " + e2); - } - } - - final String before = textBefore != null ? textBefore.toString() : ""; - final String after = textAfter != null ? textAfter.toString() : ""; - textToProcess = before + after; - - if (!shouldAppend) { - // Select all text NOW so user sees what will be processed - mConnection.selectAll(); - } else { - // If appending, we probably want to move cursor to the end so we append at the - // end of the current text? - // Or just leave cursor where it is? "generate good night wash" context is whole - // text. - // Usually "append" means add to the end of the document. - // But if cursor is in middle, maybe insert there? - // Let's assume append means "add to end of context". - // But context is "before + after". - // Let's safe bet: Move cursor to end of text. - // Because onTextInput inserts at cursor. - if (textToProcess.length() > 0) { - // We don't have absolute position easily unless we assume 0 is start. - // beginBatchEdit/endBatchEdit might be needed if we move cursor? - // Actually, we can just delete nothing and insert. - // But we need to be at the end. - // We can try: mConnection.setSelection(textToProcess.length(), - // textToProcess.length())? - // But we don't know the absolute offset of 'textBefore'. - // Wait, textBefore + textAfter = whole text. - // But we are at cursor. - // To go to end: move cursor by textAfter.length(). - if (after.length() > 0 && mConnection.getExpectedSelectionEnd() >= 0) { - int newPos = mConnection.getExpectedSelectionEnd() + after.length(); - mConnection.setSelection(newPos, newPos); - } - } - } - } + final var request = prepareAiRequest(shouldAppend); + if (request == null) return; + final String textToProcess = request.getOriginalText(); + final boolean hasSelection = request.getHasSelection(); helium314.keyboard.latin.utils.ProofreadHelper.INSTANCE.customAsync(mLatinIME, textToProcess, prompt, hasSelection, showThinking, new helium314.keyboard.latin.utils.ProofreadHelper.ProofreadCallback() { @Override public void onSuccess(String resultText) { + if (requestId != mAiRequestId || !request.isCurrent()) return; mLatinIME.onTextInput(resultText); } @Override public void onError(String errorMessage) { - Log.e(TAG, "Custom AI Error: " + errorMessage); + if (requestId != mAiRequestId || !request.isCurrent()) return; + Log.e(TAG, "Custom AI failed"); KeyboardSwitcher.getInstance().showToast("AI Error: " + errorMessage, true); } }); diff --git a/app/src/main/java/helium314/keyboard/latin/ocr/ITextRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/ocr/ITextRecognizer.kt new file mode 100644 index 000000000..b2ce3c35b --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/ocr/ITextRecognizer.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.ocr + +import android.content.Context +import android.graphics.Bitmap + +interface ITextRecognizer { + fun getInterfaceVersion(): Int = 1 + fun getScriptName(): String + fun getDisplayName(): String + fun init(context: Context) + fun recognize(bitmap: Bitmap, keepLineBreaks: Boolean): List? + fun isAvailable(): Boolean + fun release() +} diff --git a/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraManager.kt b/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraManager.kt new file mode 100644 index 000000000..34eedf91d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraManager.kt @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.ocr + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Matrix +import android.view.Surface +import androidx.annotation.MainThread +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.util.concurrent.ListenableFuture +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.prefs +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@MainThread +class OcrCameraManager( + private val context: Context, + private val getCameraProvider: () -> ListenableFuture = + { ProcessCameraProvider.getInstance(context) } +) { + private var cameraProvider: ProcessCameraProvider? = null + private var imageCapture: ImageCapture? = null + private var preview: Preview? = null + private var camera: Camera? = null + private var isTorchOn: Boolean = false + private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val lifecycleOwner = ImeLifecycleOwner() + private val mainExecutor = ContextCompat.getMainExecutor(context) + private var generation = 0L + private var captureGeneration = 0L + private var active = false + private var released = false + + companion object { + private const val TAG = "OcrCameraManager" + private const val MAX_IMAGE_DIMENSION = 1920 + private const val PREF_TORCH_CHOICE = "ocr_last_torch_enabled" + } + + private class ImeLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry(this) + + init { + registry.currentState = Lifecycle.State.CREATED + } + + fun start() { + registry.currentState = Lifecycle.State.RESUMED + } + + fun stop() { + registry.currentState = Lifecycle.State.CREATED + } + + fun destroy() { + registry.currentState = Lifecycle.State.DESTROYED + } + + override val lifecycle: Lifecycle get() = registry + } + + @SuppressLint("RestrictedApi") + fun startCamera(previewView: PreviewView, onReady: () -> Unit = {}, onError: (Exception) -> Unit = {}) { + if (released) return + stopCamera() + active = true + val request = generation + try { + val future = getCameraProvider() + future.addListener({ + if (!isCurrent(request)) return@addListener + try { + cameraProvider = future.get() + bindCamera(previewView) + if (isCurrent(request)) onReady() + } catch (e: Exception) { + if (isCurrent(request)) { + stopCamera() + Log.e(TAG, "Failed to start camera", e) + onError(e) + } + } + }, mainExecutor) + } catch (e: Exception) { + stopCamera() + onError(e) + } + } + + private fun isCurrent(request: Long) = active && !released && request == generation + + private fun bindCamera(previewView: PreviewView) { + val provider = cameraProvider ?: return + + lifecycleOwner.start() + + preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .setTargetRotation(previewView.display?.rotation ?: Surface.ROTATION_0) + .build() + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + camera = provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageCapture + ) + isTorchOn = false + if (context.prefs().getBoolean(OcrPluginLoader.PREF_OCR_PERSIST_FLASH, false) && + context.prefs().getBoolean(PREF_TORCH_CHOICE, false)) { + setTorchEnabled(true) + } + } + + fun toggleTorch(): Boolean { + if (!active || released) return false + val enabled = setTorchEnabled(!isTorchOn) + val prefs = context.prefs() + if (prefs.getBoolean(OcrPluginLoader.PREF_OCR_PERSIST_FLASH, false)) { + prefs.edit().putBoolean(PREF_TORCH_CHOICE, enabled).apply() + } else { + prefs.edit().remove(PREF_TORCH_CHOICE).apply() + } + return enabled + } + + private fun setTorchEnabled(enabled: Boolean): Boolean { + val cam = camera ?: return false + return try { + if (cam.cameraInfo.hasFlashUnit()) { + cam.cameraControl.enableTorch(enabled) + isTorchOn = enabled + isTorchOn + } else { + false + } + } catch (e: Exception) { + Log.e(TAG, "Failed to toggle torch", e) + false + } + } + + fun isTorchEnabled(): Boolean = isTorchOn + + fun focus(previewView: PreviewView, x: Float, y: Float) { + val cam = camera ?: return + try { + val factory = previewView.meteringPointFactory + val point = factory.createPoint(x, y) + val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE) + .setAutoCancelDuration(3, TimeUnit.SECONDS) + .build() + cam.cameraControl.startFocusAndMetering(action) + } catch (e: Exception) { + Log.e(TAG, "Focus failed", e) + } + } + + fun capturePhoto(onCaptured: (Bitmap) -> Unit, onError: (Exception) -> Unit) { + if (!active || released) return + val session = generation + val request = ++captureGeneration + fun deliverError(error: Exception) { + mainExecutor.execute { + if (isCurrent(session) && request == captureGeneration) onError(error) + } + } + val capture = imageCapture ?: run { + deliverError(IllegalStateException("Camera capture is not ready")) + return + } + + try { + capture.takePicture( + cameraExecutor, + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + var rawBitmap: Bitmap? = null + try { + val rotation = image.imageInfo.rotationDegrees + rawBitmap = image.toBitmap() + val scaledBitmap = scaleAndRotateBitmap(rawBitmap, rotation) + if (scaledBitmap != rawBitmap) { + rawBitmap.recycle() + } + rawBitmap = null + mainExecutor.execute { + if (isCurrent(session) && request == captureGeneration) { + onCaptured(scaledBitmap) + } else { + scaledBitmap.recycle() + } + } + } catch (e: Exception) { + rawBitmap?.recycle() + Log.e(TAG, "Error processing captured frame", e) + deliverError(e) + } finally { + image.close() + } + } + + override fun onError(exception: ImageCaptureException) { + Log.e(TAG, "Image capture error", exception) + deliverError(exception) + } + } + ) + } catch (e: Exception) { + deliverError(e) + } + } + + private fun scaleAndRotateBitmap(src: Bitmap, rotationDegrees: Int): Bitmap { + val width = src.width + val height = src.height + + val maxDim = maxOf(width, height) + val scale = if (maxDim > MAX_IMAGE_DIMENSION) { + MAX_IMAGE_DIMENSION.toFloat() / maxDim.toFloat() + } else { + 1.0f + } + + val matrix = Matrix() + if (scale < 1.0f) { + matrix.postScale(scale, scale) + } + if (rotationDegrees != 0) { + matrix.postRotate(rotationDegrees.toFloat()) + } + + return if (!matrix.isIdentity) { + Bitmap.createBitmap(src, 0, 0, width, height, matrix, true) + } else { + src + } + } + + fun stopCamera() { + generation++ + active = false + val prefs = context.prefs() + if (!prefs.getBoolean(OcrPluginLoader.PREF_OCR_PERSIST_FLASH, false)) { + prefs.edit().remove(PREF_TORCH_CHOICE).apply() + } + try { + if (isTorchOn) { + camera?.cameraControl?.enableTorch(false) + } + } catch (e: Exception) { + Log.e(TAG, "Error disabling torch", e) + } + try { + val ownedUseCases = listOfNotNull(preview, imageCapture).toTypedArray() + if (ownedUseCases.isNotEmpty()) cameraProvider?.unbind(*ownedUseCases) + } catch (e: Exception) { + Log.e(TAG, "Error unbinding camera", e) + } finally { + isTorchOn = false + camera = null + preview = null + imageCapture = null + cameraProvider = null + if (!released) lifecycleOwner.stop() + } + } + + fun release() { + if (released) return + stopCamera() + released = true + lifecycleOwner.destroy() + cameraExecutor.shutdown() + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraView.kt b/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraView.kt new file mode 100644 index 000000000..b9742f10c --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/ocr/OcrCameraView.kt @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.ocr + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import helium314.keyboard.latin.R +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.common.Colors +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.settings.SettingsActivity +import helium314.keyboard.settings.SettingsDestination + +class OcrCameraView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + interface OcrViewListener { + fun onOcrTextExtracted(lines: List) + fun onOcrTextInserted(text: String) + fun onCloseOcr() + } + + private var previewView: PreviewView? = null + private var touchShield: View? = null + private var pluginPanel: LinearLayout? = null + private var controlsBar: LinearLayout? = null + private var shutterBtn: ImageButton? = null + private var shutterProgress: ProgressBar? = null + private var flashBtn: ImageButton? = null + private var closeBtn: ImageButton? = null + private var statusIndicator: TextView? = null + + private var cameraManager: OcrCameraManager? = null + private var pipeline: OcrPipeline? = null + private var listener: OcrViewListener? = null + private var isCameraStarted = false + private var isLoadingAnimationActive = false + private var loadingAnimator: ValueAnimator? = null + private var generation = 0L + private val loadingBorderDrawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 28f + setStroke(4, Color.TRANSPARENT) + setColor(Color.TRANSPARENT) + } + + companion object { + private const val TAG = "OcrCameraView" + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (visibility != VISIBLE) { + return super.dispatchTouchEvent(ev) + } + // Let buttons and children handle their own clicks + super.dispatchTouchEvent(ev) + // Always return true so KeyboardWrapperView never falls through to MainKeyboardView + return true + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_UP) { + performClick() + previewView?.let { pv -> + cameraManager?.focus(pv, event.x, event.y) + } + } + return true + } + + @SuppressLint("ClickableViewAccessibility") + override fun onFinishInflate() { + super.onFinishInflate() + previewView = findViewById(R.id.ocr_preview_view) + touchShield = findViewById(R.id.ocr_touch_shield) + pluginPanel = findViewById(R.id.ocr_plugin_required_panel) + controlsBar = findViewById(R.id.ocr_controls_bar) + shutterBtn = findViewById(R.id.btn_ocr_shutter) + shutterProgress = findViewById(R.id.ocr_shutter_progress) + flashBtn = findViewById(R.id.btn_ocr_flash) + closeBtn = findViewById(R.id.btn_ocr_close) + statusIndicator = findViewById(R.id.ocr_status_indicator) + + previewView?.scaleType = PreviewView.ScaleType.FILL_CENTER + previewView?.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + + isClickable = true + isFocusable = true + isSoundEffectsEnabled = false + + findViewById