diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml new file mode 100644 index 000000000..a3f5715c1 --- /dev/null +++ b/.github/workflows/update-badges.yml @@ -0,0 +1,78 @@ +name: Update README Badges + +on: + schedule: + - cron: '0 0 * * *' # Midnight UTC + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-badges: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch GitHub stats + id: stats + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO="LeanBitLab/HeliboardL" + + # Latest version + VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' | sed 's/^v//' 2>/dev/null || echo "N/A") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Total downloads + DOWNLOADS=$(gh api repos/$REPO/releases --jq '[.[].assets[]?.download_count] | add // 0' 2>/dev/null || echo "0") + echo "downloads=$DOWNLOADS" >> $GITHUB_OUTPUT + + # Stars + STARS=$(gh api repos/$REPO --jq '.stargazers_count' 2>/dev/null || echo "0") + echo "stars=$STARS" >> $GITHUB_OUTPUT + + - name: Generate badge SVGs + env: + VERSION: ${{ steps.stats.outputs.version }} + DOWNLOADS: ${{ steps.stats.outputs.downloads }} + STARS: ${{ steps.stats.outputs.stars }} + run: | + mkdir -p docs/badges + + # Format numbers with commas + DOWNLOADS_FMT=$(printf "%'d" "$DOWNLOADS" 2>/dev/null || echo "$DOWNLOADS") + STARS_FMT=$(printf "%'d" "$STARS" 2>/dev/null || echo "$STARS") + + # Download version badge + cat > docs/badges/download.svg << EOF + VersionVersionv${VERSION}v${VERSION} + EOF + + # Downloads count badge + cat > docs/badges/downloads.svg << EOF + DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} + EOF + + # Stars badge + cat > docs/badges/stars.svg << EOF + StarsStars${STARS_FMT}${STARS_FMT} + EOF + + echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" + + - name: Update README badge URLs + run: | + # Replace shields.io URLs with local badge paths + sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md + sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md + sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/badges/ README.md + git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" + git push diff --git a/.gitignore b/.gitignore index bc03f7232..9e18d66b9 100755 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ docs/superpowers/ .agents .kilo/ .antigravitycli/ + +.env + +# AI agent config (personal, not shared) +.pi/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c551bbc4..566105ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,30 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [3.10.0] - 2026-06-20 + +### Added +- **Handwriting input** (Standard builds) — write characters on a recognition canvas using a + downloadable plugin, with a dedicated bottom-row layout and a toolbar key. +- **Auto-read OTP from SMS** — a one-time code from an incoming SMS is offered in the suggestion + strip while the keyboard is open; tap to insert. Uses a runtime, opt-in SMS permission. +- **Regex shortcuts in Text Expander** — expansion triggers can be matched by regular expression. + +### Changed +- **Offline AI backend switched from ONNX Runtime to llama.cpp (GGUF).** The Offline build now + loads compact quantized **GGUF** models on-device with configurable sampling + (temperature / top-p / top-k / min-p); it now requires Android 8 (API 26). +- **Touchpad gestures reworked** into a fuller one-/two-finger suite (word select, word-by-word + navigation, space, copy/paste, cut/select-all, undo/redo, hold-to-backspace). Single-finger + double-tap now **selects the word** (previously deleted the selection). +- Release builds now target the **arm64-v8a** ABI only. + +### Upstream +- Merged **LeanBitLab/LeanType v3.8.6** (from v3.8.3) — the source of the handwriting, + llama.cpp/GGUF, touchpad-gesture, and SMS-OTP changes above. Fork identity (LeanTypeDual, distinct + `applicationId`, two-thumb typing, the Gemini standard-AI layer, and the privacy tiers) is + preserved. + ## [3.9.1] - 2026-06-11 ### Fixed diff --git a/README.md b/README.md index dc4901dd7..988fcbe81 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult ### On top of that — LeanType's AI layer and quality-of-life features - **[🤖 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](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using ONNX models (Offline build only). +- **[🛡️ 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). - **🌐 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)). - **[🧠 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), backspace-to-revert, and a guide. +- **📝 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. - **↩️ Undo word** - a toolbar key that reverts the last committed word back to its suggestion alternatives. - **🗂️ Per-dictionary control** - enable or disable individual built-in and custom dictionaries. @@ -38,7 +39,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **📸 Screenshot Suggestion & Clipboard** - Recently-taken screenshots are offered in the suggestion strip and saved to clipboard history. - **🔎 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), **Offline** (network hard-disabled, offline model), or **Offline Lite** (no AI, ~20 MB). +- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI, handwriting), **Offline** (network hard-disabled, offline GGUF model), or **Offline Lite** (no AI, ~20 MB). @@ -73,20 +74,22 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult +> **⚠️ Note:** F-Droid releases might be delayed or stuck again due to reproducibility verification issues. For the latest version, use GitHub Releases or Obtainium. + ### 📦 Choose Your Version #### 1. Standard Version (`-standard-release.apk`) -* **Features:** Full suite including **AI Proofreading**, **AI Translation**, and **Gesture Library Downloader**. -* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features). -* **Setup:** Use the built-in downloader for Gesture Typing. Configure AI keys in Settings. +* **Features:** Full suite including **AI Proofreading**, **AI Translation**, **Handwriting Input**, and **Gesture Library Downloader**. +* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features, download plugins, or update libraries). +* **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** (ONNX). +* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (via `llama.cpp` using 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 ONNX models and load via *Settings > AI Integration*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#3-offline-proofreading-privacy-focused)** + * **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)** #### 3. Offline Lite Version (`-offlinelite-release.apk`) * **Features:** All UI/UX enhancements but **NO AI FEATURES**. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38fa7f669..8da584599 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,24 +16,22 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.asafmah.leantypedual" minSdk = 21 targetSdk = 35 - versionCode = 3910 - versionName = "3.9.1" + versionCode = 4000 + versionName = "3.10.0" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") ndk { - abiFilters.addAll(arrayOf("armeabi-v7a", "arm64-v8a")) + abiFilters.addAll(arrayOf("arm64-v8a")) } } - // ONNX Runtime is used instead of llama.cpp native build - flavorDimensions += "privacy" productFlavors { create("standard") { @@ -45,6 +43,7 @@ android { create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" + minSdk = 26 } create("offlinelite") { dimension = "privacy" @@ -141,7 +140,7 @@ android { path = File("src/main/jni/Android.mk") } } -// ndkVersion = "28.0.13004108" + ndkVersion = "28.0.13004108" packaging { jniLibs { @@ -235,8 +234,21 @@ dependencies { "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) - // ONNX Runtime for T5 encoder-decoder grammar models - "offlineImplementation"("com.microsoft.onnxruntime:onnxruntime-android:1.17.3") + "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") + + // 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). + // 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. + "standardImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") + "standardOptimisedImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") // test testImplementation(kotlin("test")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5feeb81f6..50d6ba6d4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -28,9 +28,6 @@ # Keep java-llama.cpp classes -keep class de.kherud.llama.** { *; } -# ONNX Runtime configurations --dontwarn com.google.protobuf.** --keep class ai.onnxruntime.** { *; } # Fix correct service name -keep class helium314.keyboard.latin.utils.ProofreadService { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a85c17db..c32d5edf0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,13 +8,15 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only - + + + @@ -27,6 +29,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:allowBackup="false" android:defaultToDeviceProtectedStorage="true" android:directBootAware="true" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true" tools:remove="android:appComponentFactory" tools:targetApi="p"> @@ -122,6 +126,34 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:resource="@xml/provider_paths" /> + + + + + + + + + + + diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json new file mode 100644 index 000000000..8cb127cfb --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json @@ -0,0 +1,8 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 } + ] +] diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json new file mode 100644 index 000000000..f83af57a2 --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json @@ -0,0 +1,9 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 }, + { "label": "action", "width": 0.15 } + ] +] diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index 133219aeb..dfb44df4d 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -4,15 +4,15 @@ ه ﻫ|ه‍ ج چ ش ڜ -ي ئ ى +ي ى ئ ب پ ل ﻻ|لا ﻷ|لأ ﻹ|لإ ﻵ|لآ -ا !fixedOrder!5 آ ء أ إ ٱ +ا !fixedOrder!5 أ ٱ إ آ ء ك گ ک ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ٕ|ٕ ٔ|ٔ ْ|ْ ٍ|ٍ ٌ|ٌ ً|ً ّ|ّ ٖ|ٖ ٰ|ٰ ٓ|ٓ ِ|ِ ُ|ُ َ|َ ـــ|ـ +punctuation !fixedOrder!7 ّ◌|ّ ْ◌|ْ َ◌|َ ِ◌|ِ ُ◌|ُ ٍ◌|ٍ ً◌|ً ٌ◌|ٌ ٓ◌|ٓ ٰ◌|ٰ ٕ◌|ٕ ٔ◌|ٔ ٖ◌|ٖ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › diff --git a/app/src/main/assets/locale_key_texts/fa.txt b/app/src/main/assets/locale_key_texts/fa.txt index e84824d74..636293b52 100644 --- a/app/src/main/assets/locale_key_texts/fa.txt +++ b/app/src/main/assets/locale_key_texts/fa.txt @@ -1,7 +1,7 @@ [popup_keys] ه ﻫ|ه‍ هٔ ة ی ئ ي ﯨ|ى -ا !fixedOrder!5 ٱ ء آ أ إ +ا !fixedOrder!5 آ ء ٱ أ إ ت ة ک ك و ؤ diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 003f05ec9..d347b04c0 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -103,6 +103,14 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { when (primaryCode) { + KeyCode.HANDWRITING -> { + if (keyboardSwitcher.isHandwritingShowing) { + keyboardSwitcher.setAlphabetKeyboard() + } else { + keyboardSwitcher.setHandwritingKeyboard() + } + return + } KeyCode.TOGGLE_AUTOCORRECT -> { settings.toggleAutoCorrect() latinIME.onOneShotSpaceActionStateChanged() @@ -587,15 +595,47 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } override fun onSingleTap() { - onCodeInput(Constants.CODE_ENTER, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(Constants.CODE_SPACE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onDoubleTap() { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onScroll(direction: Int) { + onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onTwoFingerDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_COPY, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerTap() { + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_CUT, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerSwipeLeft() { if (connection.hasSelection()) { onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } } - override fun onScroll(direction: Int) { - onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + override fun onThreeFingerSwipeRight() { + // Empty for future use + } + override fun onThreeFingerSwipeUp() { + onCodeInput(KeyCode.UNDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerSwipeDown() { + onCodeInput(KeyCode.REDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } }) } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java index 016fe5a2e..7429f5d01 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java @@ -67,6 +67,7 @@ public final class KeyboardId { public static final int ELEMENT_NUMPAD = 28; public static final int ELEMENT_EMOJI_BOTTOM_ROW = 29; public static final int ELEMENT_CLIPBOARD_BOTTOM_ROW = 30; + public static final int ELEMENT_HANDWRITING_BOTTOM_ROW = 31; public final RichInputMethodSubtype mSubtype; public final int mWidth; @@ -208,7 +209,7 @@ public boolean isEmojiKeyboard() { } public boolean isEmojiClipBottomRow() { - return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW; + return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW || mElementId == ELEMENT_HANDWRITING_BOTTOM_ROW; } public int imeAction() { @@ -290,6 +291,9 @@ public static String elementIdToName(final int elementId) { case ELEMENT_EMOJI_CATEGORY16 -> "emojiCategory16"; case ELEMENT_CLIPBOARD -> "clipboard"; case ELEMENT_NUMPAD -> "numpad"; + case ELEMENT_EMOJI_BOTTOM_ROW -> "emojiBottomRow"; + case ELEMENT_CLIPBOARD_BOTTOM_ROW -> "clipboardBottomRow"; + case ELEMENT_HANDWRITING_BOTTOM_ROW -> "handwritingBottomRow"; default -> null; }; } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java index 572b9efb2..fd3095a40 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java @@ -227,6 +227,7 @@ public static KeyboardLayoutSet buildEmojiClipBottomRow(final Context context, @ final int height = ResourceUtils.getKeyboardHeight(context.getResources(), Settings.getValues()); builder.setKeyboardGeometry(width, height); builder.setSubtype(RichInputMethodManager.getInstance().getCurrentSubtype()); + builder.setSplitLayoutEnabled(Settings.getValues().mIsSplitKeyboardEnabled); return builder.build(); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 3fab62c84..a5490480d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -42,6 +42,7 @@ import helium314.keyboard.latin.RichInputMethodManager; import helium314.keyboard.latin.RichInputMethodSubtype; import helium314.keyboard.latin.WordComposer; +import helium314.keyboard.latin.handwriting.HandwritingView; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.suggestions.SuggestionStripView; @@ -69,6 +70,7 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private SuggestionStripView mSuggestionStripView; private LinearLayout mStripContainer; private ClipboardHistoryView mClipboardHistoryView; + private HandwritingView mHandwritingView; private TouchpadView mTouchpadView; private TextView mFakeToastView; private LatinIME mLatinIME; @@ -93,6 +95,10 @@ public static KeyboardSwitcher getInstance() { return sInstance; } + public LatinIME getLatinIME() { + return mLatinIME; + } + private KeyboardSwitcher() { // Intentional empty constructor for singleton. } @@ -355,6 +361,12 @@ private void setMainKeyboardFrame( mSuggestionStripView.setVisibility(stripVisibility); mClipboardHistoryView.setVisibility(View.GONE); mClipboardHistoryView.stopClipboardHistory(); + if (mHandwritingView != null) { + if (mHandwritingView.isShown()) { + mHandwritingView.stopHandwriting(); + } + mHandwritingView.setVisibility(View.GONE); + } if (PointerTracker.sPersistentTouchpadModeActive) { if (mTouchpadView != null) { @@ -378,6 +390,10 @@ public void setEmojiKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setEmojiKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -402,6 +418,10 @@ public void setClipboardKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setClipboardKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -421,6 +441,45 @@ public void setClipboardKeyboard() { mClipboardHistoryView.setVisibility(View.VISIBLE); } + public void setHandwritingKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setHandwritingKeyboard"); + } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.GONE); + mEmojiTabStripView.setVisibility(View.GONE); + mSuggestionStripView.setVisibility(View.VISIBLE); + mStripContainer.setVisibility(View.VISIBLE); + mClipboardStripScrollView.setVisibility(View.GONE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + + if (mHandwritingView != null) { + final RichInputMethodSubtype subtype = mRichImm.getCurrentSubtype(); + final String language = subtype.getLocale().toLanguageTag(); + mHandwritingView.startHandwriting( + mLatinIME.getCurrentInputEditorInfo(), + mLatinIME.mKeyboardActionListener, + language + ); + mHandwritingView.setVisibility(View.VISIBLE); + } + } + + public boolean isHandwritingShowing() { + return mHandwritingView != null && mHandwritingView.isShown(); + } + + public void clearHandwritingCanvas() { + if (mHandwritingView != null) { + mHandwritingView.clearCanvasAndComposition(); + } + } + @Override public void setNumpadKeyboard() { if (DEBUG_ACTION) { @@ -760,6 +819,8 @@ public View getVisibleKeyboardView() { return mEmojiPalettesView; } else if (isShowingClipboardHistory()) { return mClipboardHistoryView; + } else if (isHandwritingShowing()) { + return mHandwritingView; } return mKeyboardView; } @@ -828,6 +889,7 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mMainKeyboardFrame = mCurrentInputView.findViewById(R.id.main_keyboard_frame); mEmojiPalettesView = mCurrentInputView.findViewById(R.id.emoji_palettes_view); mClipboardHistoryView = mCurrentInputView.findViewById(R.id.clipboard_history_view); + mHandwritingView = mCurrentInputView.findViewById(R.id.handwriting_view); mFakeToastView = mCurrentInputView.findViewById(R.id.fakeToast); mKeyboardViewWrapper = mCurrentInputView.findViewById(R.id.keyboard_view_wrapper); @@ -839,6 +901,9 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mEmojiPalettesView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); mClipboardHistoryView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); mClipboardHistoryView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); + if (mHandwritingView != null) { + mHandwritingView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); + } mEmojiTabStripView = mCurrentInputView.findViewById(R.id.emoji_tab_strip); mClipboardStripView = mCurrentInputView.findViewById(R.id.clipboard_strip); mClipboardStripScrollView = mCurrentInputView.findViewById(R.id.clipboard_strip_scroll_view); diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index 87460447d..e1151832f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -34,6 +34,13 @@ public interface TouchpadListener { void onSingleTap(); void onDoubleTap(); void onScroll(int direction); + void onTwoFingerDoubleTap(); + void onThreeFingerTap(); + void onThreeFingerDoubleTap(); + void onThreeFingerSwipeLeft(); + void onThreeFingerSwipeRight(); + void onThreeFingerSwipeUp(); + void onThreeFingerSwipeDown(); } private TouchpadListener mListener; @@ -52,12 +59,61 @@ public interface TouchpadListener { // Two-finger scroll tracking private boolean mIsTwoFingerScroll; + private float mTwoFingerLastX; private float mTwoFingerLastY; + private float mScrollAccX; private float mScrollAccY; + private float mTwoFingerStartX; + private float mTwoFingerStartY; + private boolean mHasScrolledHorizontally; + private boolean mIsTwoFingerLongPress; + private int mTwoFingerTapCount = 0; + + private final Runnable mTwoFingerLongPressRunnable = new Runnable() { + @Override + public void run() { + if (mIsTwoFingerTap) { + mIsTwoFingerLongPress = true; + if (mListener != null) { + mListener.onThreeFingerSwipeLeft(); + } + postDelayed(this, 150); + } + } + }; // Two-finger tap tracking private boolean mIsTwoFingerTap; private long mTwoFingerDownTime; + private final Runnable mTwoFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) { + if (mTwoFingerTapCount == 1) { + mListener.onSingleTap(); + } else if (mTwoFingerTapCount == 2) { + mListener.onTwoFingerDoubleTap(); + } else if (mTwoFingerTapCount >= 3) { + mListener.onThreeFingerDoubleTap(); + } + } + mTwoFingerTapCount = 0; + } + }; + + // Three-finger tap & swipe tracking + private boolean mIsThreeFingerTap; + private long mThreeFingerDownTime; + private long mLastThreeFingerTapTime = 0; + private final Runnable mThreeFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) mListener.onThreeFingerTap(); + } + }; + private boolean mIsThreeFingerSwipe; + private float mThreeFingerStartX; + private float mThreeFingerStartY; private static final int SCROLL_THRESHOLD = 40; @@ -172,9 +228,14 @@ private void setupTouchSurface() { mTouchpadSurface.setOnTouchListener((v, event) -> { mGestureDetector.onTouchEvent(event); final int pointerCount = event.getPointerCount(); + android.util.Log.i("TouchpadViewRaw", "action=" + MotionEvent.actionToString(event.getActionMasked()) + ", pointers=" + pointerCount); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: + android.util.Log.i("TouchpadView", "ACTION_DOWN"); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } mLastTouchX = event.getX(); mLastTouchY = event.getY(); mAccX = 0; @@ -184,32 +245,83 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_DOWN: + android.util.Log.i("TouchpadView", "ACTION_POINTER_DOWN: pointerCount=" + pointerCount); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } if (pointerCount == 2) { mIsTwoFingerScroll = true; mIsTwoFingerTap = true; mTwoFingerDownTime = System.currentTimeMillis(); mIsDragging = false; - mTwoFingerLastY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerStartX = (event.getX(0) + event.getX(1)) / 2f; + mTwoFingerStartY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerLastX = mTwoFingerStartX; + mTwoFingerLastY = mTwoFingerStartY; + mScrollAccX = 0; mScrollAccY = 0; + mHasScrolledHorizontally = false; + mIsTwoFingerLongPress = false; + + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerLongPressRunnable, 400); } return true; case MotionEvent.ACTION_MOVE: if (mIsTwoFingerScroll && pointerCount >= 2) { + float midX = (event.getX(0) + event.getX(1)) / 2f; float midY = (event.getY(0) + event.getY(1)) / 2f; - float deltaY = midY - mTwoFingerLastY; - mTwoFingerLastY = midY; - mScrollAccY += deltaY; + float deltaX = midX - mTwoFingerStartX; + float deltaY = midY - mTwoFingerStartY; - while (mScrollAccY >= SCROLL_THRESHOLD) { + float density = getContext().getResources().getDisplayMetrics().density; + + if (Math.abs(midX - mTwoFingerStartX) > 5f * density || Math.abs(midY - mTwoFingerStartY) > 5f * density) { mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_DOWN); - mScrollAccY -= SCROLL_THRESHOLD; + removeCallbacks(mTwoFingerLongPressRunnable); } - while (mScrollAccY <= -SCROLL_THRESHOLD) { + + float swipeThreshold = 35f * density; + if (!mHasScrolledHorizontally && Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > swipeThreshold) { + mIsTwoFingerScroll = false; mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_UP); - mScrollAccY += SCROLL_THRESHOLD; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + + if (mListener != null) { + if (deltaY < 0) { + mListener.onThreeFingerSwipeUp(); + } else { + mListener.onThreeFingerSwipeDown(); + } + } + } else { + float lastDeltaX = midX - mTwoFingerLastX; + mTwoFingerLastX = midX; + mTwoFingerLastY = midY; + + mScrollAccX += lastDeltaX; + + while (mScrollAccX >= SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_RIGHT); + mScrollAccX -= SCROLL_THRESHOLD; + } + while (mScrollAccX <= -SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_LEFT); + mScrollAccX += SCROLL_THRESHOLD; + } } } else if (mIsDragging && pointerCount == 1) { float x = event.getX(); @@ -257,11 +369,28 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: + android.util.Log.i("TouchpadView", "ACTION_UP"); mIsDragging = false; stopEdgeScrolling(); mIsTwoFingerScroll = false; mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + if (mSelectionMode) { + mSelectionMode = false; + applySurfaceColor(); + } + return true; + + case MotionEvent.ACTION_CANCEL: + android.util.Log.i("TouchpadView", "ACTION_CANCEL"); + mIsDragging = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -269,9 +398,19 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_UP: + android.util.Log.i("TouchpadView", "ACTION_POINTER_UP: pointerCount=" + pointerCount); if (pointerCount == 2) { + removeCallbacks(mTwoFingerLongPressRunnable); + if (mIsTwoFingerLongPress) { + mIsTwoFingerLongPress = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + return true; + } if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { - if (mListener != null) mListener.onSingleTap(); + mTwoFingerTapCount++; + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerTapRunnable, 250); } mIsTwoFingerScroll = false; mIsTwoFingerTap = false; diff --git a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt index fe33fbf42..8fd6b69ab 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt @@ -34,6 +34,8 @@ import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ResourceUtils import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick import helium314.keyboard.latin.utils.getEnabledClipboardToolbarKeys @@ -202,8 +204,21 @@ class ClipboardHistoryView @JvmOverloads constructor( val clipboardStrip = KeyboardSwitcher.getInstance().clipboardStrip toolbarKeys.forEach { clipboardStrip.addView(it) - it.setOnClickListener(this@ClipboardHistoryView) - it.setOnLongClickListener(this@ClipboardHistoryView) + val tag = it.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + it.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + keyboardActionListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, repeatCount > 0) + } + }) + } else { + it.setOnClickListener(this@ClipboardHistoryView) + it.setOnLongClickListener(this@ClipboardHistoryView) + } colors.setColor(it, ColorType.TOOL_BAR_KEY) it.setBackgroundResource(R.drawable.toolbar_key_background) colors.setColor(it.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) @@ -419,6 +434,7 @@ class ClipboardHistoryView @JvmOverloads constructor( private fun setBottomRowLayout(elementId: Int) { val editorInfo = this.editorInfo ?: return val keyboardView = findViewById(R.id.bottom_row_keyboard) + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) keyboardView.setKeyboardActionListener(this) // Set 'this' as listener to intercept PointerTracker.switchTo(keyboardView) // Use Builder to get correct layout. Match EmojiPalettesView's search-mode setup diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 8eb8995c2..b83c53b10 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -68,18 +68,15 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; import helium314.keyboard.latin.AudioAndHapticFeedbackManager; import helium314.keyboard.latin.SingleDictionaryFacilitator; -import helium314.keyboard.latin.dictionary.Dictionary; import helium314.keyboard.latin.dictionary.DictionaryFactory; import helium314.keyboard.latin.R; import helium314.keyboard.latin.RichInputMethodManager; import helium314.keyboard.latin.RichInputMethodSubtype; -import helium314.keyboard.latin.SingleDictionaryFacilitator; import helium314.keyboard.latin.common.ColorType; import helium314.keyboard.latin.common.Colors; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.suggestions.SuggestionStripView; -import helium314.keyboard.latin.utils.DictionaryInfoUtils; import helium314.keyboard.latin.utils.ResourceUtils; import helium314.keyboard.latin.common.StringUtilsKt; @@ -311,6 +308,7 @@ public void initialize() { // needs to be delayed for access to EmojiTabStrip, w androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL, false)); mSearchAdapter = new EmojiSearchAdapter(emoji -> { mKeyboardActionListener.onTextInput(emoji); + addRecentKey(emoji); // Optionally close search or keep it open for multiple inputs? // restore standard behavior: stop search stopSearchMode(); @@ -721,6 +719,7 @@ public void resetMetaState() { KeyboardLayoutSet kls = builder.build(); bottomRow.setKeyboard(kls.getKeyboard(KeyboardId.ELEMENT_ALPHABET)); + bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); // Focus mSearchBar.requestFocus(); @@ -833,6 +832,7 @@ private void setupBottomRowKeyboard(final EditorInfo editorInfo, if (keyboardView == null || !this.isAttachedToWindow()) { return; } + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); EditorInfo ei = editorInfo != null ? editorInfo : mEditorInfo; keyboardView.setKeyboardActionListener(keyboardActionListener); 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 4fdc0600a..0cd860269 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -44,6 +44,7 @@ class KeyboardIconsSet private constructor() { } val baseIds = defaultIds.toMutableMap().apply { put(ToolbarKey.CLEAR_CLIPBOARD.name.lowercase(Locale.US), clearClipboardResId) + put("clear_handwriting", R.drawable.ic_close) } val overrideIds = customIconIds(context, prefs) val ids = if (overrideIds.isEmpty()) baseIds else baseIds + overrideIds @@ -166,6 +167,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_holo ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_holo ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -248,6 +250,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_lxx ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_lxx ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -330,6 +333,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap_rounded ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_rounded ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_rounded ToolbarKey.LEFT -> R.drawable.ic_dpad_left_rounded ToolbarKey.RIGHT -> R.drawable.ic_dpad_right_rounded diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt index 083abb1ae..40d4a91c1 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt @@ -57,6 +57,7 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co LayoutType.NUMPAD_LANDSCAPE else LayoutType.NUMPAD KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW -> LayoutType.EMOJI_BOTTOM KeyboardId.ELEMENT_CLIPBOARD_BOTTOM_ROW -> LayoutType.CLIPBOARD_BOTTOM + KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW -> LayoutType.HANDWRITING_BOTTOM else -> LayoutType.MAIN } val baseKeys = LayoutParser.parseLayout(layoutType, params, context) 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 82dbcf2b7..ac3227cc9 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 @@ -199,6 +199,8 @@ object KeyCode { const val CUSTOM_AI_9 = -10069 const val CUSTOM_AI_10 = -10070 const val CLIPBOARD_SEARCH = -10071 + const val HANDWRITING = -10074 + const val CLEAR_HANDWRITING = -10075 // Intents @@ -225,7 +227,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 + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING, CLEAR_HANDWRITING -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt index 9c213ab0e..abde5f96f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt @@ -90,6 +90,7 @@ object KeyLabel { fun keyLabelToActualLabel(label: String, params: KeyboardParams): String { val newLabel = when (label) { + "clear_handwriting" -> "!icon/clear_handwriting" SYMBOL_ALPHA -> if (params.mId.isAlphabetKeyboard) params.mLocaleKeyboardInfos.labelSymbol else params.mLocaleKeyboardInfos.labelAlphabet SYMBOL -> params.mLocaleKeyboardInfos.labelSymbol ALPHA -> params.mLocaleKeyboardInfos.labelAlphabet @@ -117,6 +118,7 @@ object KeyLabel { else label } val code = when (label) { // maybe a bit lazy to not assemble the entire string above + "clear_handwriting" -> KeyCode.CLEAR_HANDWRITING SYMBOL_ALPHA -> KeyCode.SYMBOL_ALPHA SYMBOL -> KeyCode.SYMBOL ALPHA -> KeyCode.ALPHA diff --git a/app/src/main/java/helium314/keyboard/latin/App.kt b/app/src/main/java/helium314/keyboard/latin/App.kt index 18e6bcbb5..86ad69460 100644 --- a/app/src/main/java/helium314/keyboard/latin/App.kt +++ b/app/src/main/java/helium314/keyboard/latin/App.kt @@ -2,6 +2,7 @@ package helium314.keyboard.latin import android.app.Application +import androidx.work.Configuration import helium314.keyboard.keyboard.emoji.SupportedEmojis import helium314.keyboard.latin.define.DebugFlags import helium314.keyboard.latin.settings.Defaults @@ -10,7 +11,14 @@ import helium314.keyboard.latin.utils.LayoutUtilsCustom import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SubtypeSettings -class App : Application() { +class App : Application(), Configuration.Provider { + + // WorkManager Configuration.Provider — required for ML Kit Digital Ink plugin. + // The plugin is loaded via DexClassLoader and calls WorkManager.getInstance(context) + // internally. This ensures WorkManager can self-initialize via the Application. + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().build() + override fun onCreate() { super.onCreate() DebugFlags.init(this) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index 3164003a1..559865ad8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -113,6 +113,8 @@ void resetDictionaries( /** permanently blocks the word: removes it from editable dictionaries and adds it to the blacklist */ void blockWord(String word); + void reloadBlacklist(); + 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 a6195d678..8d69b59a8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -62,6 +62,7 @@ import java.util.concurrent.TimeUnit @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mPrefs: SharedPreferences? = null + private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var dictionaryGroups = listOf(DictionaryGroup()) @@ -78,13 +79,15 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var changeFrom = "" private var changeTo = "" + private var mLoadedSuggestEmojis: Boolean = false + private var mLoadedEmojiDictExists: Boolean = false + private val SPELLING_DICTIONARY_TYPES = arrayOf( Dictionary.TYPE_MAIN, Dictionary.TYPE_CONTACTS, Dictionary.TYPE_APPS, Dictionary.TYPE_USER_HISTORY ) - // Caches for spell checking word validity private var mValidSpellingWordReadCache: LruCache? = LruCache(500) private var mValidSpellingWordWriteCache: LruCache? = LruCache(500) @@ -144,6 +147,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { return false } } + val ctx = mContext ?: return false + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, ctx) != null } + if (currentSuggestEmojis != mLoadedSuggestEmojis || currentEmojiDictExists != mLoadedEmojiDictExists) { + return false + } val dictGroup = dictionaryGroups[0] // settings are the same for all groups return contacts == dictGroup.hasDict(Dictionary.TYPE_CONTACTS) && apps == dictGroup.hasDict(Dictionary.TYPE_APPS) @@ -165,6 +174,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { listener: DictionaryInitializationListener? ) { Log.i(TAG, "resetDictionaries, force reloading main dictionary: $forceReloadMainDictionary") + mContext = context.applicationContext val prefs = context.prefs() mPrefs = prefs mEnabledDictionariesState = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") } @@ -237,7 +247,11 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // create new or re-use already loaded main dict val mainDict: Dictionary? - if (forceReload || oldDictGroupForLocale == null + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(locale, Dictionary.TYPE_EMOJI, context) != null + val forceReloadMain = forceReload || (currentSuggestEmojis != mLoadedSuggestEmojis) || (currentEmojiDictExists != mLoadedEmojiDictExists) + + if (forceReloadMain || oldDictGroupForLocale == null || !oldDictGroupForLocale.hasDict(Dictionary.TYPE_MAIN) ) { mainDict = null // null main dicts will be loaded later in asyncReloadUninitializedMainDictionaries @@ -276,6 +290,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { scope.launch { try { val useEmojiDict = Settings.getValues().mSuggestEmojis + mLoadedSuggestEmojis = useEmojiDict + mLoadedEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, context) != null } val dictGroupsWithNewMainDict = locales.mapNotNull { val dictionaryGroup = findDictionaryGroupWithLocale(dictionaryGroups, it) if (dictionaryGroup == null) { @@ -711,6 +727,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } + override fun reloadBlacklist() { + for (dictionaryGroup in dictionaryGroups) { + dictionaryGroup.reloadBlacklist() + } + } + override fun addToUserDictionary(word: String) { if (word.isEmpty()) return val group = currentlyPreferredDictionaryGroup @@ -874,6 +896,12 @@ private class DictionaryGroup( /** 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) { + addToBlacklist(word) + val lowercase = word.lowercase(locale) + if (word != lowercase) { + addToBlacklist(lowercase) + } + // remove from user history getSubDict(Dictionary.TYPE_USER_HISTORY)?.removeUnigramEntryDynamically(word) @@ -883,27 +911,12 @@ private class DictionaryGroup( val contactsDict = getSubDict(Dictionary.TYPE_CONTACTS) if (contactsDict != null && contactsDict.isInDictionary(word)) { contactsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return } val appsDict = getSubDict(Dictionary.TYPE_APPS) if (appsDict != null && appsDict.isInDictionary(word)) { appsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return - } - val mainDict = mainDict - if (mainDict != null && mainDict.isValidWord(word)) { - addToBlacklist(word) - return - } - - val lowercase = word.lowercase(locale) - if (mainDict != null && mainDict.isValidWord(lowercase)) { - addToBlacklist(lowercase) - return } // The word was in no read-only dictionary (main/contacts/apps) — it only lived in the mutable @@ -978,44 +991,134 @@ private class DictionaryGroup( else null } + @Volatile + private var compiledBlacklistPatterns: List = emptyList() + + private fun rebuildCompiledPatterns() { + compiledBlacklistPatterns = blacklist.map { pattern -> + try { + Regex(pattern, RegexOption.IGNORE_CASE) + } catch (e: Exception) { + Regex(Regex.escape(pattern), RegexOption.IGNORE_CASE) + } + } + } + private val blacklist = hashSetOf().apply { - if (blacklistFile?.isFile != true) return@apply + val file = blacklistFile + if (file == null) return@apply scope.launch { synchronized(blacklistLock) { try { - addAll(blacklistFile.readLines()) + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + addAll(loadedWords) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } } - fun isBlacklisted(word: String) = blacklist.contains(word) + fun isBlacklisted(word: String): Boolean { + val patterns = compiledBlacklistPatterns + return patterns.any { it.matches(word) } + } fun addToBlacklist(word: String) { - if (!blacklist.add(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + synchronized(blacklistLock) { + if (!blacklist.add(lowercase)) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - if (blacklistFile.isDirectory) blacklistFile.delete() - blacklistFile.appendText("$word\n") + if (file.isDirectory) file.delete() + file.appendText("$lowercase\n") + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isDirectory) baseFile.delete() + baseFile.appendText("$lowercase\n") + } } catch (e: IOException) { - Log.e(TAG, "Exception while trying to add word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${file.name}", e) } } } } fun removeFromBlacklist(word: String) { - if (!blacklist.remove(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + synchronized(blacklistLock) { + if (!blacklist.remove(lowercase)) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - val newLines = blacklistFile.readLines().filterNot { it == word } - blacklistFile.writeText(newLines.joinToString("\n")) + val files = mutableListOf(file) + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + files.add(File(file.parentFile, "${locale.language}.txt")) + } + for (f in files) { + if (f.isFile) { + val lines = f.readLines() + val newLines = lines.filterNot { it.lowercase(locale) == lowercase } + if (newLines.size != lines.size) { + f.writeText(newLines.joinToString("\n") + if (newLines.isEmpty()) "" else "\n") + } + } + } + } catch (e: IOException) { + Log.e(TAG, "Exception while trying to remove word \"$word\" from blacklist ${file.name}", e) + } + } + } + } + + fun reloadBlacklist() { + val file = blacklistFile + if (file == null) { + synchronized(blacklistLock) { + blacklist.clear() + rebuildCompiledPatterns() + } + return + } + scope.launch { + synchronized(blacklistLock) { + try { + blacklist.clear() + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + blacklist.addAll(loadedWords) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index a670093a7..b2577c3c4 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -182,6 +182,7 @@ public void onReceive(Context context, Intent intent) { private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this); + private final OtpSuggestionManager mOtpSuggestionManager = new OtpSuggestionManager(this); private FloatingKeyboardManager mFloatingKeyboardManager; @@ -711,6 +712,7 @@ public void onDestroy() { mFloatingKeyboardManager.destroy(); } mClipboardHistoryManager.onDestroy(); + mOtpSuggestionManager.stop(); mDictionaryFacilitator.closeDictionaries(); mSettings.onDestroy(); unregisterReceiver(mRingerModeChangeReceiver); @@ -915,6 +917,7 @@ private void onStartInputInternal(final EditorInfo editorInfo, final boolean res void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) { super.onStartInputView(editorInfo, restarting); + helium314.keyboard.latin.utils.ProofreadHelper.preloadModel(this); mDictionaryFacilitator.onStartInput(); // Switch to the null consumer to handle cases leading to early exit below, for @@ -1093,6 +1096,10 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart if (TRACE) Debug.startMethodTracing("/data/trace/latinime"); + + // Listen for incoming SMS OTPs only while the keyboard is shown, and only if the + // user has opted in and granted the permission (handled inside the manager). + mOtpSuggestionManager.start(); } @Override @@ -1130,6 +1137,7 @@ void onFinishInputInternal() { void onFinishInputViewInternal(final boolean finishingInput) { super.onFinishInputView(finishingInput); Log.i(TAG, "onFinishInputView"); + mOtpSuggestionManager.stop(); cleanupInternalStateForFinishInput(); } @@ -1702,9 +1710,12 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { mKeyboardSwitcher.getKeyboardShiftMode(), mHandler); updateStateAfterInputTransaction(completeInputTransaction); + if (mKeyboardSwitcher.isHandwritingShowing()) { + mKeyboardSwitcher.clearHandwritingCanvas(); + } - if (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI - .equals(suggestionInfo.mSourceDict.mDictType)) { + if (suggestionInfo.isEmoji() || (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI + .equals(suggestionInfo.mSourceDict.mDictType))) { final helium314.keyboard.keyboard.emoji.EmojiPalettesView emojiView = mKeyboardSwitcher .getEmojiPalettesView(); if (emojiView != null) { @@ -1718,6 +1729,21 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { * in suggestion strip. * returns whether a clipboard suggestion has been set. */ + /** + * Checks if a recent SMS OTP suggestion is available. If so, it is set in the suggestion strip. + * Returns whether an OTP suggestion has been set. + */ + public boolean tryShowOtpSuggestion() { + if (!hasSuggestionStripView()) return false; + final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView); + if (otpView != null) { + // false: the OTP chip layout already has its own close button (wired in the manager) + mSuggestionStripView.setExternalSuggestionView(otpView, false); + return true; + } + return false; + } + public boolean tryShowClipboardSuggestion() { final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(), mSuggestionStripView); @@ -1743,8 +1769,8 @@ public boolean tryShowClipboardSuggestion() { @Override public void setNeutralSuggestionStrip() { final SettingsValues currentSettings = mSettings.getCurrent(); - if (tryShowClipboardSuggestion()) { - // clipboard suggestion has been set + if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) { + // an external (OTP or clipboard) suggestion has been set if (hasSuggestionStripView() && currentSettings.mAutoHideToolbar) mSuggestionStripView.setToolbarVisibility(false); return; @@ -1781,6 +1807,11 @@ public void onOneShotSpaceActionStateChanged() { @Override public void removeSuggestion(final String word) { mDictionaryFacilitator.removeWord(word); + mInputLogic.getSuggest().clearNextWordSuggestionsCache(); + } + + public DictionaryFacilitator getDictionaryFacilitator() { + return mDictionaryFacilitator; } @Override diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt new file mode 100644 index 000000000..669c36482 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package helium314.keyboard.latin + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.Looper +import android.provider.Telephony +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.databinding.OtpSuggestionBinding +import helium314.keyboard.latin.permissions.PermissionsUtil +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.ToolbarKey + +/** + * Optional, opt-in helper that surfaces one-time passcodes (OTPs) from incoming SMS as a + * suggestion-strip chip the user can tap to insert (similar to the clipboard/screenshot + * suggestions, see [ClipboardHistoryManager.getClipboardSuggestionView]). + * + * Privacy: this never reads the existing SMS inbox. A [BroadcastReceiver] is registered only + * while the keyboard input view is shown and only when the feature is enabled and the + * RECEIVE_SMS permission has been granted, so the keyboard only ever sees messages that arrive + * while the user is actively typing. + */ +class OtpSuggestionManager(private val latinIME: LatinIME) { + + private val mainHandler = Handler(Looper.getMainLooper()) + private var otpSuggestionView: View? = null + private var dontShowCurrentSuggestion = false + + private var latestOtp: String? = null + private var latestOtpTimestamp = 0L + + private var isRegistered = false + private val smsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + val body = try { + Telephony.Sms.Intents.getMessagesFromIntent(intent) + ?.joinToString(separator = "") { it.messageBody ?: it.displayMessageBody ?: "" } + ?: return + } catch (e: Exception) { + Log.w(TAG, "Failed to read incoming SMS", e) + return + } + val otp = extractOtp(body) ?: return + latestOtp = otp + latestOtpTimestamp = System.currentTimeMillis() + dontShowCurrentSuggestion = false + // Refresh the strip on the main thread so the chip appears immediately, + // mirroring the screenshot-observer path in ClipboardHistoryManager. + mainHandler.post { + if (latinIME.isInputViewShown) latinIME.setNeutralSuggestionStrip() + } + } + } + + /** Register the SMS receiver if the feature is enabled and the permission is granted. Idempotent. */ + fun start() { + if (isRegistered) return + if (!latinIME.mSettings.current.mAutoReadOtp) return + if (!PermissionsUtil.checkAllPermissionsGranted(latinIME, Manifest.permission.RECEIVE_SMS)) return + try { + ContextCompat.registerReceiver( + latinIME, + smsReceiver, + IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION), + // EXPORTED is required: SMS_RECEIVED is delivered by the system/telephony process + // (an external sender), so a NOT_EXPORTED receiver never receives it. This is safe + // because SMS_RECEIVED is a protected broadcast that only the system can send. + ContextCompat.RECEIVER_EXPORTED + ) + isRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Could not register SMS receiver", e) + } + } + + /** Unregister the receiver. Idempotent. Called when the input view is hidden or the IME is destroyed. */ + fun stop() { + if (!isRegistered) return + try { + latinIME.unregisterReceiver(smsReceiver) + } catch (e: Exception) { + Log.w(TAG, "Could not unregister SMS receiver", e) + } + isRegistered = false + } + + /** + * Build the OTP suggestion chip if a recent code is available, else null. + * Called from [LatinIME.tryShowOtpSuggestion]. + */ + fun getOtpSuggestionView(parent: ViewGroup?): View? { + otpSuggestionView = null + if (parent == null) return null + if (!latinIME.mSettings.current.mAutoReadOtp) return null + if (dontShowCurrentSuggestion) return null + val otp = latestOtp ?: return null + if (System.currentTimeMillis() - latestOtpTimestamp > RECENT_OTP_MILLIS) return null + + val binding = OtpSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) + val textView = binding.otpSuggestionText + latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it } + textView.text = otp + val icon = latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.NUMPAD.name.lowercase()) + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + textView.setOnClickListener { + dontShowCurrentSuggestion = true + latinIME.onTextInput(otp) + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + binding.root.isGone = true + } + val closeButton = binding.otpSuggestionClose + closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) + closeButton.setOnClickListener { removeOtpSuggestion() } + + val colors = latinIME.mSettings.current.mColors + textView.setTextColor(colors.get(ColorType.KEY_TEXT)) + icon?.let { colors.setColor(it, ColorType.KEY_ICON) } + colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) + colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND) + + otpSuggestionView = binding.root + return otpSuggestionView + } + + private fun removeOtpSuggestion() { + dontShowCurrentSuggestion = true + val view = otpSuggestionView ?: return + if (view.parent != null && !view.isGone) { + latinIME.setNeutralSuggestionStrip() + latinIME.mHandler.postResumeSuggestions(false) + } + view.isGone = true + } + + /** + * Extract an OTP from an SMS body. Keyword-gated to limit false positives: a 4-8 digit group is + * only treated as a code when the message mentions a code-like keyword, or when it is the single + * such group in the message. + */ + private fun extractOtp(body: String): String? { + if (body.isBlank()) return null + val groups = codeRegex.findAll(body).map { it.value }.toList() + if (groups.isEmpty()) return null + return if (otpKeywordRegex.containsMatchIn(body) || groups.size == 1) groups.first() else null + } + + companion object { + private const val TAG = "OtpSuggestionManager" + private const val RECENT_OTP_MILLIS = 60 * 1000L // OTP chip is offered for 60s after arrival + private val codeRegex = Regex("\\b\\d{4,8}\\b") + private val otpKeywordRegex = Regex( + "otp|code|passcode|password|pin|verification|verify|one[- ]?time|2fa|auth", + RegexOption.IGNORE_CASE + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt index a27bb6e60..cf7176dc7 100644 --- a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt +++ b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt @@ -134,6 +134,8 @@ class SingleDictionaryFacilitator(private val dict: Dictionary) : DictionaryFaci override fun blockWord(word: String) {} + override fun reloadBlacklist() {} + override fun clearUserHistoryDictionary(context: Context) {} override fun localesAndConfidences(): String? = null diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt new file mode 100644 index 000000000..41a30ac5f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +class HandwritingCanvas @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val strokePaint = Paint().apply { + color = 0xFF3F51B5.toInt() // Default blue, will be overridden by theme later + style = Paint.Style.STROKE + strokeWidth = 10f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + isAntiAlias = true + } + + private val path = Path() + private val strokes = mutableListOf() + private var currentStroke = mutableListOf() + private var startTime: Long = 0 + private var isRecognitionDone = false + + private val mainHandler = Handler(Looper.getMainLooper()) + private val recognitionTimeout = 700L + private val recognizeRunnable = Runnable { + isRecognitionDone = true + onRecognitionTriggered?.invoke(ArrayList(strokes)) + } + + var onRecognitionTriggered: ((List) -> Unit)? = null + var onStrokeStarted: (() -> Unit)? = null + + fun setStrokeColor(color: Int) { + strokePaint.color = color + invalidate() + } + + fun clear() { + mainHandler.removeCallbacks(recognizeRunnable) + path.reset() + strokes.clear() + currentStroke.clear() + isRecognitionDone = false + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPath(path, strokePaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val x = event.x + val y = event.y + val time = event.eventTime + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + mainHandler.removeCallbacks(recognizeRunnable) + if (isRecognitionDone) { + onStrokeStarted?.invoke() + isRecognitionDone = false + } + path.moveTo(x, y) + startTime = time + currentStroke.clear() + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add(0f) // Relative time start + invalidate() + } + MotionEvent.ACTION_MOVE -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + invalidate() + } + MotionEvent.ACTION_UP -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + strokes.add(currentStroke.toFloatArray()) + currentStroke.clear() + invalidate() + + mainHandler.postDelayed(recognizeRunnable, recognitionTimeout) + } + } + return true + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt new file mode 100644 index 000000000..9731bd67d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import dalvik.system.DexClassLoader +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.prefs +import java.io.File + +object HandwritingLoader { + private const val PLUGIN_FILENAME = "handwriting_plugin.apk" + private const val PLUGIN_CLASS_NAME = "helium314.keyboard.handwriting.plugin.HandwritingRecognizerImpl" + private const val PREF_HAS_PLUGIN = "pref_handwriting_has_plugin" + + private var activeRecognizer: HandwritingRecognizer? = null + + fun getRecognizer(context: Context): HandwritingRecognizer? { + if (activeRecognizer != null) return activeRecognizer + if (!hasPlugin(context)) return null + + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) { + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + return null + } + apkFile.setReadOnly() + + try { + val md5 = java.security.MessageDigest.getInstance("MD5") + val bytes = apkFile.readBytes() + val hash = md5.digest(bytes).joinToString("") { "%02x".format(it) } + Log.i("HandwritingLoader", "Loaded plugin APK path: ${apkFile.absolutePath}, size: ${bytes.size}, md5: $hash") + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to calculate MD5", e) + } + + try { + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + activeRecognizer = recognizer + return recognizer + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to load handwriting plugin", e) + } + return null + } + + fun hasPlugin(context: Context): Boolean { + return context.prefs().getBoolean(PREF_HAS_PLUGIN, false) + } + + fun getPluginVersion(context: Context): String? { + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) return null + return try { + val info = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + info?.versionName + } catch (e: Exception) { + null + } + } + + + fun importPlugin(context: Context, uri: Uri): Boolean { + try { + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (apkFile.exists()) { + apkFile.delete() + } + context.contentResolver.openInputStream(uri)?.use { input -> + apkFile.outputStream().use { output -> + input.copyTo(output) + } + } + apkFile.setReadOnly() + + // Verify the plugin loads successfully + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, true).apply() + activeRecognizer = recognizer + return true + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to import plugin APK", e) + // Cleanup on failure + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } + return false + } + + fun removePlugin(context: Context) { + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt new file mode 100644 index 000000000..308a0ecdf --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context + +interface ModelDownloadListener { + fun onProgress(progress: Float) + fun onComplete(success: Boolean) +} + +interface HandwritingRecognizer { + fun init(context: Context) + fun setLanguage(language: String): Boolean + fun isLanguageReady(language: String): Boolean + fun downloadModel(language: String, listener: ModelDownloadListener) + fun recognize(strokes: List): List? +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt new file mode 100644 index 000000000..39a6227e3 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.graphics.drawable.GradientDrawable +import helium314.keyboard.keyboard.KeyboardActionListener +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.latin.AudioAndHapticFeedbackManager +import helium314.keyboard.latin.R +import helium314.keyboard.latin.RichInputConnection +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.SuggestedWords +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.dictionary.Dictionary +import android.view.inputmethod.EditorInfo +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.latin.RichInputMethodManager +import helium314.keyboard.latin.utils.LanguageOnSpacebarUtils + +class HandwritingView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), KeyboardActionListener { + + private lateinit var languageLabel: TextView + private lateinit var clearButton: ImageButton + private lateinit var canvas: HandwritingCanvas + private lateinit var bottomRowKeyboard: MainKeyboardView + + private var keyboardActionListener: KeyboardActionListener? = null + private var editorInfo: EditorInfo? = null + private var currentLanguage: String = "" + + private var currentComposingText = "" + + override fun onFinishInflate() { + super.onFinishInflate() + languageLabel = findViewById(R.id.handwriting_language_label) + clearButton = findViewById(R.id.handwriting_clear_button) + canvas = findViewById(R.id.handwriting_canvas) + bottomRowKeyboard = findViewById(R.id.handwriting_bottom_row_keyboard) + + clearButton.setOnClickListener { + clearCanvasAndComposition() + } + + canvas.onStrokeStarted = { + commitCurrentComposition() + canvas.clear() + } + + canvas.onRecognitionTriggered = { strokes -> + performRecognition(strokes) + canvas.clear() + } + } + + fun startHandwriting( + editorInfo: EditorInfo, + keyboardActionListener: KeyboardActionListener, + language: String + ) { + this.editorInfo = editorInfo + this.keyboardActionListener = keyboardActionListener + this.currentLanguage = language + + val colors = Settings.getValues().mColors + val toolbar = findViewById(R.id.handwriting_toolbar) + if (toolbar != null) { + colors.setBackground(toolbar, ColorType.MAIN_BACKGROUND) + } + colors.setBackground(canvas, ColorType.MAIN_BACKGROUND) + + languageLabel.setTextColor(colors.get(ColorType.KEY_TEXT)) + colors.setColor(clearButton, ColorType.KEY_ICON) + canvas.setStrokeColor(colors.get(ColorType.KEY_TEXT)) + + languageLabel.text = language + + // Setup bottom row keyboard + bottomRowKeyboard.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) + bottomRowKeyboard.setKeyboardActionListener(this) + + try { + PointerTracker.switchTo(bottomRowKeyboard) + val kls = KeyboardLayoutSet.Builder.buildEmojiClipBottomRow(context, editorInfo) + val keyboard = kls.getKeyboard(KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW) + bottomRowKeyboard.setKeyboard(keyboard) + + val languageOnSpacebarFormatType = LanguageOnSpacebarUtils.getLanguageOnSpacebarFormatType(keyboard.mId.mSubtype) + val hasMultipleEnabledIMEsOrSubtypes = RichInputMethodManager.getInstance().hasMultipleEnabledIMEsOrSubtypes(true) + bottomRowKeyboard.startDisplayLanguageOnSpacebar( + true, + languageOnSpacebarFormatType, + hasMultipleEnabledIMEsOrSubtypes + ) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to setup bottom row keyboard", e) + } + + clearCanvasAndComposition() + + val hasPlugin = HandwritingLoader.hasPlugin(context) + val overlay = findViewById(R.id.handwriting_plugin_overlay) + if (!hasPlugin) { + overlay?.visibility = View.VISIBLE + val titleText = findViewById(R.id.handwriting_plugin_title) + val summaryText = findViewById(R.id.handwriting_plugin_summary) + val iconView = findViewById(R.id.handwriting_plugin_icon) + val button = findViewById(R.id.handwriting_plugin_button) + + if (titleText != null) titleText.setTextColor(colors.get(ColorType.KEY_TEXT)) + if (summaryText != null) summaryText.setTextColor(colors.get(ColorType.KEY_HINT_TEXT)) + if (iconView != null) colors.setColor(iconView, ColorType.KEY_ICON) + + if (button != null) { + val btnBackground = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 8f * context.resources.displayMetrics.density + setColor(colors.get(ColorType.ACTION_KEY_BACKGROUND)) + } + button.background = btnBackground + button.setTextColor(colors.get(ColorType.KEY_TEXT)) + + button.setOnClickListener { + val intent = android.content.Intent() + intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java) + intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries) + intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + try { + context.startActivity(intent) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to start settings activity", e) + } + KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0) + } + } + } else { + overlay?.visibility = View.GONE + } + + val recognizer = HandwritingLoader.getRecognizer(context) + if (recognizer != null) { + recognizer.setLanguage(language) + recognitionExecutor.execute { + val isReady = recognizer.isLanguageReady(language) + mainHandler.post { + if (!isReady) { + languageLabel.text = "$language (Downloading...)" + recognizer.downloadModel(language, object : ModelDownloadListener { + override fun onProgress(progress: Float) { + mainHandler.post { + languageLabel.text = "$language (Downloading ${"%.0f".format(progress * 100)}%)" + } + } + override fun onComplete(success: Boolean) { + mainHandler.post { + if (success) { + languageLabel.text = language + android.widget.Toast.makeText(context, "Handwriting model downloaded", android.widget.Toast.LENGTH_SHORT).show() + } else { + languageLabel.text = "$language (Download failed)" + android.widget.Toast.makeText(context, "Failed to download handwriting model", android.widget.Toast.LENGTH_LONG).show() + } + } + } + }) + } else { + languageLabel.text = language + } + } + } + } + } + + fun stopHandwriting() { + commitCurrentComposition() + canvas.clear() + bottomRowKeyboard.closing() + } + + fun setHardwareAcceleratedDrawingEnabled(enabled: Boolean) { + if (enabled) { + setLayerType(LAYER_TYPE_HARDWARE, null) + } + } + + fun commitCurrentComposition() { + if (currentComposingText.isNotEmpty()) { + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return + val ic = latinIME.currentInputConnection ?: return + ic.finishComposingText() + currentComposingText = "" + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + fun clearCanvasAndComposition() { + canvas.clear() + currentComposingText = "" + val latinIME = KeyboardSwitcher.getInstance().latinIME + if (latinIME != null) { + val ic = latinIME.currentInputConnection + if (ic != null) { + ic.finishComposingText() + } + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + private val recognitionExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + + private fun performRecognition(strokes: List) { + if (strokes.isEmpty()) return + val recognizer = HandwritingLoader.getRecognizer(context) ?: return + + // setLanguage is fast (no blocking I/O), safe on main thread + recognizer.setLanguage(currentLanguage) + + // recognize() uses Tasks.await() which must not run on main thread + recognitionExecutor.execute { + try { + val results = recognizer.recognize(strokes) + if (results.isNullOrEmpty()) return@execute + + mainHandler.post { + val mainCandidate = results[0] + + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return@post + val ic = latinIME.currentInputConnection ?: return@post + + if (currentComposingText.isNotEmpty()) { + ic.finishComposingText() + val textBefore = ic.getTextBeforeCursor(1, 0) + if (textBefore != null && textBefore.isNotEmpty() && textBefore != " " && textBefore != "\n") { + ic.commitText(" ", 1) + } + } + + currentComposingText = mainCandidate + + // Update composing text + ic.setComposingText(mainCandidate, 1) + + // Populate suggestion strip with alternative candidates + val suggestionInfos = ArrayList() + for (word in results) { + suggestionInfos.add( + SuggestedWordInfo( + word, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + + val typedWordInfo = SuggestedWordInfo( + mainCandidate, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + + val suggestedWords = SuggestedWords( + suggestionInfos, + null, + typedWordInfo, + false, + false, + false, + SuggestedWords.INPUT_STYLE_TYPING, + SuggestedWords.NOT_A_SEQUENCE_NUMBER + ) + latinIME.setSuggestions(suggestedWords) + } + } catch (e: Exception) { + Log.e("HandwritingView", "Error during recognition", e) + } + } + } + + // Intercept KeyboardActionListener events for the bottom row + override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { + if (primaryCode == KeyCode.ALPHA) { + // Close handwriting mode + KeyboardSwitcher.getInstance().setAlphabetKeyboard() + return + } + if (primaryCode == KeyCode.CLEAR_HANDWRITING) { + clearCanvasAndComposition() + return + } + + // For other keys, commit the composition first when relevant + if (primaryCode == Constants.CODE_SPACE || primaryCode == Constants.CODE_ENTER) { + commitCurrentComposition() + } + + keyboardActionListener?.onCodeInput(primaryCode, x, y, isKeyRepeat) + } + + override fun onTextInput(text: String) { + commitCurrentComposition() + keyboardActionListener?.onTextInput(text) + } + + override fun onImageSelected(imageUri: String?) { + keyboardActionListener?.onImageSelected(imageUri) + } + + override fun onPressKey(primaryCode: Int, repeatCount: Int, isSinglePointer: Boolean, hapticEvent: HapticEvent?) { + keyboardActionListener?.onPressKey(primaryCode, repeatCount, isSinglePointer, hapticEvent) + } + + override fun onReleaseKey(primaryCode: Int, withSliding: Boolean) { + keyboardActionListener?.onReleaseKey(primaryCode, withSliding) + } + + override fun onLongPressKey(primaryCode: Int) { + keyboardActionListener?.onLongPressKey(primaryCode) + } + + override fun onKeyDown(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyDown(keyCode, keyEvent) ?: false + } + + override fun onKeyUp(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyUp(keyCode, keyEvent) ?: false + } + + override fun onStartBatchInput() { keyboardActionListener?.onStartBatchInput() } + override fun onUpdateBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onUpdateBatchInput(p) } + override fun onEndBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onEndBatchInput(p) } + override fun onCancelBatchInput() { keyboardActionListener?.onCancelBatchInput() } + override fun onCancelInput() { keyboardActionListener?.onCancelInput() } + override fun onFinishSlidingInput() { keyboardActionListener?.onFinishSlidingInput() } + override fun onCustomRequest(requestCode: Int): Boolean { return keyboardActionListener?.onCustomRequest(requestCode) ?: false } + override fun onHorizontalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onHorizontalSpaceSwipe(steps) ?: false } + override fun onVerticalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onVerticalSpaceSwipe(steps) ?: false } + override fun onEndSpaceSwipe() { keyboardActionListener?.onEndSpaceSwipe() } + override fun toggleNumpad(w: Boolean, f: Boolean): Boolean { return keyboardActionListener?.toggleNumpad(w, f) ?: false } + override fun onMoveDeletePointer(steps: Int) { keyboardActionListener?.onMoveDeletePointer(steps) } + override fun onUpWithDeletePointerActive() { keyboardActionListener?.onUpWithDeletePointerActive() } + override fun resetMetaState() { keyboardActionListener?.resetMetaState() } +} 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 4ac354a4d..79c939f1b 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -95,6 +95,11 @@ 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; @@ -559,6 +564,15 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, fin } } + if (oldSelStart != newSelStart || oldSelEnd != newSelEnd) { + if (newSelStart != mLastExpandedCursorPosition) { + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + } + } + final boolean selectionChangedOrSafeToReset = oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection // changed || !mWordComposer.isComposingWord(); // safe to reset @@ -1399,6 +1413,9 @@ 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. @@ -1416,6 +1433,43 @@ 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. *

@@ -2166,6 +2220,32 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set consumeJoinNextActionAndNotifyIfChanged(); // Combining mode: arm/refresh the grace timer for the next input. enterCombiningMode(settingsValues, true /* fromTap, unused — kept for clarity */); + if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME) + && helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) { + final String typedWord = mWordComposer.getTypedWord(); + final String prefix = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getPrefix(mLatinIME); + if (prefix.isEmpty()) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(typedWord, mLatinIME); + if (expanded != null) { + commitExpandedText(typedWord, expanded); + resetComposingState(true); + } + } else { + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final String textStr = textBefore.toString(); + final String targetSuffix = prefix + typedWord; + if (textStr.toLowerCase(java.util.Locale.US).endsWith(targetSuffix.toLowerCase(java.util.Locale.US))) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(targetSuffix, mLatinIME); + if (expanded != null) { + mConnection.deleteTextBeforeCursor(prefix.length()); + commitExpandedText(targetSuffix, expanded); + resetComposingState(true); + } + } + } + } + } } else { final boolean swapWeakSpace = tryStripSpaceAndReturnWhetherShouldSwapInstead(event, inputTransaction); @@ -2448,6 +2528,28 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu } } + if (mLastExpandedText != null && !event.isKeyRepeat()) { + final int expectedCursor = mConnection.getExpectedSelectionEnd(); + if (expectedCursor == mLastExpandedCursorPosition) { + final int beforeLen = mLastExpandedCursorOffset; + final int afterLen = mLastExpandedText.length() - beforeLen; + final CharSequence textBefore = mConnection.getTextBeforeCursor(beforeLen, 0); + final CharSequence textAfter = mConnection.getTextAfterCursor(afterLen, 0); + final String expectedBefore = mLastExpandedText.substring(0, beforeLen); + final String expectedAfter = mLastExpandedText.substring(beforeLen); + if (textBefore != null && textBefore.toString().equals(expectedBefore) + && textAfter != null && textAfter.toString().equals(expectedAfter)) { + mConnection.setSelection(expectedCursor - beforeLen, expectedCursor + afterLen); + mConnection.commitText(mLastShortcutText, 1); + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + return; + } + } + } + // In many cases after backspace, we need to update the shift state. Normally we // need // to do this right away to avoid the shift state being out of date in case the @@ -2788,6 +2890,33 @@ private boolean trySwapSwapperAndSpace(final Event event, return true; } + private static boolean isSpaceStrippingPunctuation(final int codePoint) { + return codePoint == '.' + || codePoint == ',' + || codePoint == ';' + || codePoint == ':' + || codePoint == '!' + || codePoint == '?' + || codePoint == ')' + || codePoint == ']' + || codePoint == '}' + || codePoint == '؟' // Arabic question mark + || codePoint == '،' // Arabic comma + || codePoint == '؛' // Arabic semicolon + || codePoint == '।' // Hindi Danda + || codePoint == '॥' // Hindi Double Danda + || codePoint == '。' // CJK full stop + || codePoint == '、' // CJK enumeration comma + || codePoint == ',' // CJK fullwidth comma + || codePoint == '?' // CJK fullwidth question mark + || codePoint == '!' // CJK fullwidth exclamation mark + || codePoint == ':' // CJK fullwidth colon + || codePoint == ';' // CJK fullwidth semicolon + || codePoint == ')' // CJK fullwidth closing parenthesis + || codePoint == '】' // CJK fullwidth closing bracket + || codePoint == '』'; // CJK fullwidth closing quote + } + /* * Strip a trailing space if necessary and returns whether it's a swap weak * space situation. @@ -2807,6 +2936,14 @@ private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event mConnection.removeTrailingSpace(); return false; } + + if (isSpaceStrippingPunctuation(codePoint) + && !inputTransaction.getSettingsValues().isUsuallyPrecededBySpace(codePoint)) { + if (mConnection.getCodePointBeforeCursor() == Constants.CODE_SPACE) { + mConnection.removeTrailingSpace(); + } + } + if ((SpaceState.WEAK == inputTransaction.getSpaceState() || SpaceState.SWAP_PUNCTUATION == inputTransaction.getSpaceState()) && isFromSuggestionStrip) { @@ -4603,35 +4740,43 @@ private void handleCustomAIKey(int index) { final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils .getSharedPreferences(mLatinIME); String prompt = prefs.getString("pref_custom_ai_prompt_" + index, ""); - String systemInstruction = ""; + StringBuilder systemInstructionBuilder = new StringBuilder(); boolean shouldAppend = false; // Keyword parsing for system instructions / personas if (prompt.contains("#editor")) { - systemInstruction = " You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."; + systemInstructionBuilder.append(" You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."); prompt = prompt.replace("#editor", "").trim(); - } else if (prompt.contains("#outputonly")) { - systemInstruction = " Output ONLY the result. Do not add introductions or explanations."; + } + if (prompt.contains("#outputonly")) { + systemInstructionBuilder.append(" Output ONLY the result. Do not add introductions or explanations."); prompt = prompt.replace("#outputonly", "").trim(); - } else if (prompt.contains("#proofread")) { - systemInstruction = " You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."; + } + if (prompt.contains("#proofread")) { + systemInstructionBuilder.append(" You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."); prompt = prompt.replace("#proofread", "").trim(); - } else if (prompt.contains("#paraphrase")) { - systemInstruction = " You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."; + } + if (prompt.contains("#paraphrase")) { + systemInstructionBuilder.append(" You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."); prompt = prompt.replace("#paraphrase", "").trim(); - } else if (prompt.contains("#summarize")) { - systemInstruction = " You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."; + } + if (prompt.contains("#summarize")) { + systemInstructionBuilder.append(" You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."); prompt = prompt.replace("#summarize", "").trim(); - } else if (prompt.contains("#expand")) { - systemInstruction = " You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."; + } + if (prompt.contains("#expand")) { + systemInstructionBuilder.append(" You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."); prompt = prompt.replace("#expand", "").trim(); - } else if (prompt.contains("#toneshift")) { - systemInstruction = " You are a tone modifier. Adjust the tone as requested. Output ONLY the result."; + } + if (prompt.contains("#toneshift")) { + systemInstructionBuilder.append(" You are a tone modifier. Adjust the tone as requested. Output ONLY the result."); prompt = prompt.replace("#toneshift", "").trim(); - } else if (prompt.contains("#generate")) { - systemInstruction = " You are a creative content generator. Output ONLY the generated content."; + } + if (prompt.contains("#generate")) { + systemInstructionBuilder.append(" You are a creative content generator. Output ONLY the generated content."); prompt = prompt.replace("#generate", "").trim(); } + String systemInstruction = systemInstructionBuilder.toString(); // Input handling keywords if (prompt.contains("#append")) { diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index c0e875464..c0c14c172 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -40,6 +40,7 @@ object Defaults { LayoutType.CLIPBOARD_BOTTOM -> "clip_bottom_row" LayoutType.SHORTCUT_TOP -> "shortcut_top" LayoutType.SHORTCUT_BOTTOM -> "shortcut_bottom" + LayoutType.HANDWRITING_BOTTOM -> "handwriting_bottom_row" } const val PREF_SPLIT_TOOLBAR = false @@ -118,6 +119,7 @@ object Defaults { const val PREF_SUGGEST_CLIPBOARD_CONTENT = true const val PREF_SUGGEST_SCREENSHOTS = false const val PREF_COMPRESS_SCREENSHOTS = true + const val PREF_AUTO_READ_OTP = false const val PREF_GESTURE_INPUT = true const val PREF_VIBRATION_DURATION_SETTINGS = -1 const val PREF_VIBRATION_AMPLITUDE_SETTINGS = -1 @@ -186,10 +188,15 @@ object Defaults { const val PREF_FORCE_AUTO_CAPS = false const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling + const val PREF_OFFLINE_TOP_K = 40 + const val PREF_OFFLINE_MIN_P = 0.05f + const val PREF_OFFLINE_SHOW_THINKING = false const val PREF_OFFLINE_SYSTEM_PROMPT = "Correct the grammar and spelling. Output only the corrected text." + const val PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "Translate the following text to {lang}. Output only the translation, nothing else:\n\n" const val PREF_OFFLINE_MAX_TOKENS = 64 // Accurate (64 tokens) default const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false + const val PREF_AI_ALLOW_INSECURE_CONNECTIONS = false const val PREF_ENABLE_CLIPBOARD_HISTORY = true const val PREF_CLIPBOARD_HISTORY_RETENTION_TIME = 15 // minutes const val PREF_CLIPBOARD_HISTORY_PINNED_FIRST = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index cf89a5379..64f1c3b70 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -220,14 +220,20 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_FORCE_AUTO_CAPS = "force_auto_caps"; public static final String PREF_OFFLINE_TEMP = "offline_temp"; public static final String PREF_OFFLINE_TOP_P = "offline_top_p"; + public static final String PREF_OFFLINE_TOP_K = "offline_top_k"; + public static final String PREF_OFFLINE_MIN_P = "offline_min_p"; + public static final String PREF_OFFLINE_SHOW_THINKING = "offline_show_thinking"; public static final String PREF_OFFLINE_SYSTEM_PROMPT = "offline_system_prompt"; + public static final String PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "offline_translate_system_prompt"; public static final String PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "offline_translate_target_language"; public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; + public static final String PREF_AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections"; public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; public static final String PREF_COMPRESS_SCREENSHOTS = "compress_screenshots"; + public static final String PREF_AUTO_READ_OTP = "auto_read_otp"; public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "clipboard_history_retention_time"; public static final String PREF_CLIPBOARD_HISTORY_PINNED_FIRST = "clipboard_history_pinned_first"; public static final String PREF_CLIPBOARD_FOLD_PINNED = "clipboard_fold_pinned"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 95efd7756..5101f5a71 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -208,6 +208,7 @@ public class SettingsValues { public final boolean mSuggestClipboardContent; public final boolean mSuggestScreenshots; public final boolean mCompressScreenshots; + public final boolean mAutoReadOtp; public final SettingsValuesForSuggestion mSettingsValuesForSuggestion; public final boolean mIncognitoModeEnabled; public final boolean mLongPressSymbolsForNumpad; @@ -311,6 +312,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT); mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS); + mAutoReadOtp = prefs.getBoolean(Settings.PREF_AUTO_READ_OTP, + Defaults.PREF_AUTO_READ_OTP); mCompressScreenshots = prefs.getBoolean(Settings.PREF_COMPRESS_SCREENSHOTS, Defaults.PREF_COMPRESS_SCREENSHOTS); mDoubleSpacePeriodTimeout = 1100; // ms diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index 95e9ea8fd..db2701b7b 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -54,6 +54,8 @@ import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.ToolbarMode import helium314.keyboard.latin.utils.addPinnedKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick @@ -920,8 +922,21 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } private fun setupKey(view: ImageButton, colors: Colors) { - view.setOnClickListener(this) - view.setOnLongClickListener(this) + val tag = view.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + view.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, view, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + listener.onCodeInput(code, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, repeatCount > 0) + } + }) + } else { + view.setOnClickListener(this) + view.setOnLongClickListener(this) + } colors.setColor(view, ColorType.TOOL_BAR_KEY) // Set circular background for toolbar keys view.setBackgroundResource(R.drawable.toolbar_key_background) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt index c64459746..2e0856b83 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt @@ -9,7 +9,7 @@ import java.util.EnumMap enum class LayoutType { MAIN, SYMBOLS, MORE_SYMBOLS, FUNCTIONAL, NUMBER, NUMBER_ROW, NUMPAD, NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM, - SHORTCUT_TOP, SHORTCUT_BOTTOM; + SHORTCUT_TOP, SHORTCUT_BOTTOM, HANDWRITING_BOTTOM; companion object { fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY) @@ -40,6 +40,7 @@ enum class LayoutType { CLIPBOARD_BOTTOM -> R.string.layout_clip_bottom_row SHORTCUT_TOP -> R.string.layout_shortcut_top SHORTCUT_BOTTOM -> R.string.layout_shortcut_bottom + HANDWRITING_BOTTOM -> R.string.layout_emoji_bottom_row } fun getMainLayoutFromExtraValue(extraValue: String): String? { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 5c91ebe86..eff85bce3 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -15,12 +15,18 @@ import java.util.Locale object TextExpanderUtils { const val PREF_ENABLED = "pref_text_expander_enabled" const val PREF_PREFIX = "pref_text_expander_prefix" + const val PREF_IMMEDIATE = "pref_text_expander_immediate" const val PREF_DATA = "pref_text_expander_data" + const val REGEX_PREFIX = "__regex__:" fun isEnabled(context: Context): Boolean { return context.prefs().getBoolean(PREF_ENABLED, false) } + fun isImmediateEnabled(context: Context): Boolean { + return context.prefs().getBoolean(PREF_IMMEDIATE, false) + } + fun getPrefix(context: Context): String { return context.prefs().getString(PREF_PREFIX, "") ?: "" } @@ -195,8 +201,27 @@ object TextExpanderUtils { val shortcuts = getShortcuts(context) // Check exact match or lowercase match - val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] ?: return null + val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] + if (template != null) { + return expand(template, context) + } + + // Check regex matches + for ((key, value) in shortcuts) { + if (key.startsWith(REGEX_PREFIX)) { + val patternStr = key.substring(REGEX_PREFIX.length) + try { + val regex = Regex(patternStr, RegexOption.IGNORE_CASE) + if (regex.matches(shortcut)) { + val replaced = regex.replace(shortcut, value) + return expand(replaced, context) + } + } catch (e: Exception) { + // ignore invalid regex + } + } + } - return expand(template, context) + return null } } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index de7de8c46..2d38093cb 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -12,6 +12,11 @@ import android.widget.ImageButton import android.widget.ImageView import androidx.core.content.edit import androidx.core.graphics.ColorUtils +import android.view.View +import android.view.MotionEvent +import android.os.Handler +import android.os.Looper +import android.annotation.SuppressLint import androidx.core.view.forEach import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode @@ -263,6 +268,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool CLIPBOARD -> KeyCode.CLIPBOARD CLIPBOARD_SEARCH -> KeyCode.CLIPBOARD_SEARCH NUMPAD -> KeyCode.NUMPAD + HANDWRITING -> KeyCode.HANDWRITING UNDO -> KeyCode.UNDO REDO -> KeyCode.REDO SETTINGS -> KeyCode.SETTINGS @@ -334,7 +340,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { - VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, + VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, INCOGNITO, TOUCHPAD, AUTOCORRECT, AUTOSPACE, AUTO_CAP, FORCE_AUTO_CAP, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, PROOFREAD, TRANSLATE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, @@ -348,11 +354,13 @@ enum class ToolbarMode { val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) { it.toString().lowercase(Locale.US) } private val excludedKeys by lazy { - val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised") + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised" && BuildConfig.FLAVOR != "offline") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") - listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH) + listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) + else if (BuildConfig.FLAVOR == "offline") + listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH, HANDWRITING) else listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH) customAiKeys + otherKeys @@ -360,9 +368,9 @@ private val excludedKeys by lazy { val defaultToolbarPref by lazy { val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { - "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) + "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE) - else -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) + else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) } val others = entries.filterNot { it in default || it in excludedKeys } @@ -397,12 +405,13 @@ fun upgradeToolbarPrefs(prefs: SharedPreferences) { private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: String) { if (!prefs.contains(pref)) return - val list = prefs.getString(pref, default)!!.split(Separators.ENTRY).toMutableList() + val originalString = prefs.getString(pref, default)!! + val list = originalString.split(Separators.ENTRY).toMutableList() val splitDefault = default.split(Separators.ENTRY) splitDefault.forEach { entry -> val keyWithSeparator = entry.substringBefore(Separators.KV) + Separators.KV if (list.none { it.startsWith(keyWithSeparator) }) - list.add("${keyWithSeparator}false") + list.add(entry) } // likely not needed, but better prepare for possibility of key removal list.removeAll { @@ -413,7 +422,10 @@ private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: true } } - prefs.edit { putString(pref, list.joinToString(Separators.ENTRY)) } + val newString = list.joinToString(Separators.ENTRY) + if (newString != originalString) { + prefs.edit { putString(pref, newString) } + } } fun getEnabledToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_TOOLBAR_KEYS, defaultToolbarPref) @@ -449,7 +461,8 @@ private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, defaul val split = it.split(Separators.KV) if (split.last() == "true") { try { - ToolbarKey.valueOf(split.first()) + val key = ToolbarKey.valueOf(split.first()) + if (key in excludedKeys) null else key } catch (_: IllegalArgumentException) { null } @@ -491,3 +504,54 @@ fun clearCustomToolbarKeyCodes() { } private var customToolbarKeyCodes: EnumMap>? = null + +fun isRepeatableToolbarKey(key: ToolbarKey): Boolean { + return when (key) { + LEFT, RIGHT, UP, DOWN, + WORD_LEFT, WORD_RIGHT, + PAGE_UP, PAGE_DOWN -> true + else -> false + } +} + +class RepeatableKeyTouchListener( + private val onClick: (repeatCount: Int) -> Unit +) : View.OnTouchListener { + private val handler = Handler(Looper.getMainLooper()) + private var repeatCount = 0 + private val runnable = object : Runnable { + override fun run() { + repeatCount++ + onClick(repeatCount) + handler.postDelayed(this, 50L) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + repeatCount = 0 + onClick(0) + handler.postDelayed(runnable, 400L) + v.isPressed = true + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + handler.removeCallbacks(runnable) + v.isPressed = false + return true + } + MotionEvent.ACTION_MOVE -> { + val x = event.x + val y = event.y + if (x < 0 || x > v.width || y < 0 || y > v.height) { + handler.removeCallbacks(runnable) + v.isPressed = false + } + return true + } + } + return false + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index a9189ec0c..a9dd8b6dc 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -244,7 +244,7 @@ fun SearchScreen( content() } } else { - val items = remember(searchText.text) { filteredItems(searchText.text) } + val items = remember(searchText.text, filteredItems) { filteredItems(searchText.text) } Scaffold( contentWindowInsets = WindowInsets(0) ) { innerPadding -> diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index 5dc52e6a5..7360609e4 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -115,4 +115,5 @@ object SettingsWithoutKey { const val GROQ_MODEL = "groq_model" const val CUSTOM_AI_KEYS = "custom_ai_keys" const val OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded" + const val AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections" } diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index 2697a9d2f..ffd245550 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -31,6 +31,7 @@ import helium314.keyboard.settings.screens.LanguageScreen import helium314.keyboard.settings.screens.MainSettingsScreen import helium314.keyboard.settings.screens.PersonalDictionariesScreen import helium314.keyboard.settings.screens.PersonalDictionaryScreen +import helium314.keyboard.settings.screens.BlockedWordsScreen import helium314.keyboard.settings.screens.PreferencesScreen import helium314.keyboard.settings.screens.SecondaryLayoutScreen import helium314.keyboard.settings.screens.SubtypeScreen @@ -150,6 +151,9 @@ fun SettingsNavHost( composable(SettingsDestination.PersonalDictionaries) { PersonalDictionariesScreen(onClickBack = ::goBack) } + composable(SettingsDestination.BlockedWords) { + BlockedWordsScreen(onClickBack = ::goBack) + } composable(SettingsDestination.Languages) { LanguageScreen(onClickBack = ::goBack) } @@ -196,6 +200,7 @@ object SettingsDestination { const val ColorsNight = "colors_night/" const val PersonalDictionaries = "personal_dictionaries" const val PersonalDictionary = "personal_dictionary/" + const val BlockedWords = "blocked_words" const val Languages = "languages" const val Subtype = "subtype/" const val Layouts = "layouts" diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index 00c51dae6..94a70790f 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -12,10 +12,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Checkbox import helium314.keyboard.dictionarypack.DictionaryPackConstants import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.keyboard.emoji.SupportedEmojis @@ -56,17 +67,74 @@ fun BackupRestorePreference(setting: Setting) { var showDialog by rememberSaveable { mutableStateOf(false) } val ctx = LocalContext.current var error: String? by rememberSaveable { mutableStateOf(null) } - val backupLauncher = backupLauncher { error = it } - val restoreLauncher = restoreLauncher { error = it } + var selectedCategories by remember { + mutableStateOf( + setOf( + BackupCategory.LAYOUTS, + BackupCategory.THEME_APPEARANCE, + BackupCategory.DICTIONARY_HISTORY, + BackupCategory.CLIPBOARD, + BackupCategory.GENERAL_SETTINGS + ) + ) + } + val backupLauncher = backupLauncher(selectedCategories) { error = it } + val restoreLauncher = restoreLauncher(selectedCategories) { error = it } Preference(name = setting.title, onClick = { showDialog = true }) if (showDialog) { ConfirmationDialog( onDismissRequest = { showDialog = false }, title = { Text(stringResource(R.string.backup_restore_title)) }, - content = { Text(stringResource(R.string.backup_restore_message)) }, + content = { + Column { + Text( + text = stringResource(R.string.backup_select_items), + modifier = Modifier.padding(bottom = 8.dp) + ) + val categories = listOf( + BackupCategory.LAYOUTS to R.string.backup_category_layouts, + BackupCategory.THEME_APPEARANCE to R.string.backup_category_theme, + BackupCategory.DICTIONARY_HISTORY to R.string.backup_category_dictionary, + BackupCategory.CLIPBOARD to R.string.backup_category_clipboard, + BackupCategory.GENERAL_SETTINGS to R.string.backup_category_general + ) + categories.forEach { (category, stringResId) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .toggleable( + value = selectedCategories.contains(category), + onValueChange = { checked -> + selectedCategories = if (checked) { + selectedCategories + category + } else { + selectedCategories - category + } + } + ) + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = selectedCategories.contains(category), + onCheckedChange = null + ) + Text( + text = stringResource(stringResId), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.backup_restore_message)) + } + }, confirmButtonText = stringResource(R.string.button_backup), neutralButtonText = stringResource(R.string.button_restore), onNeutral = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } showDialog = false val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -74,6 +142,10 @@ fun BackupRestorePreference(setting: Setting) { restoreLauncher.launch(intent) }, onConfirmed = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } val currentDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Calendar.getInstance().time) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -97,32 +169,40 @@ fun BackupRestorePreference(setting: Setting) { } @Composable -private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun backupLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> - // zip all files matching the backup patterns - // essentially this is the typed words information, and user-added dictionaries val filesDir = ctx.filesDir ?: return@filePicker val filesPath = filesDir.path + File.separator val files = mutableListOf() filesDir.walk().forEach { file -> val path = file.path.replace(filesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - files.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + files.add(file) + } + } } val protectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) val protectedFilesPath = protectedFilesDir.path + File.separator val protectedFiles = mutableListOf() protectedFilesDir.walk().forEach { file -> val path = file.path.replace(protectedFilesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - protectedFiles.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + protectedFiles.add(file) + } + } } val wait = CountDownLatch(1) ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { try { ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os -> - // write files to zip val zipStream = ZipOutputStream(os) files.forEach { val fileStream = FileInputStream(it).buffered() @@ -138,27 +218,40 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun fileStream.close() zipStream.closeEntry() } - val dbFile = ctx.getDatabasePath(Database.NAME) - if (dbFile.exists()) { - val fileStream = FileInputStream(dbFile).buffered() - zipStream.putNextEntry(ZipEntry(Database.NAME)) - fileStream.copyTo(zipStream, 1024) - fileStream.close() - zipStream.closeEntry() + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + val dbFile = ctx.getDatabasePath(Database.NAME) + if (dbFile.exists()) { + val fileStream = FileInputStream(dbFile).buffered() + zipStream.putNextEntry(ZipEntry(Database.NAME)) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + } + val filteredPrefs = ctx.prefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) } zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) - settingsToJsonStream(ctx.prefs().all, zipStream) + settingsToJsonStream(filteredPrefs, zipStream) zipStream.closeEntry() + + val filteredProtectedPrefs = ctx.protectedPrefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) - settingsToJsonStream(ctx.protectedPrefs().all, zipStream) + settingsToJsonStream(filteredProtectedPrefs, zipStream) zipStream.closeEntry() - // back up auxiliary SharedPreferences files used by individual features - // (gemini_prefs is intentionally excluded: it is EncryptedSharedPreferences - // whose values are tied to a device-specific master key and contains API keys) + for ((entryName, prefsForBackup) in auxiliaryPrefsToBackUp(ctx)) { - zipStream.putNextEntry(ZipEntry(entryName)) - settingsToJsonStream(prefsForBackup.all, zipStream) - zipStream.closeEntry() + val cat = getCategoryForFilePath(entryName) + if (cat == null || selectedCategories.contains(cat)) { + val filteredAuxPrefs = prefsForBackup.all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } + zipStream.putNextEntry(ZipEntry(entryName)) + settingsToJsonStream(filteredAuxPrefs, zipStream) + zipStream.closeEntry() + } } zipStream.close() } @@ -174,7 +267,10 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun } @Composable -private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun restoreLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> val wait = CountDownLatch(1) @@ -186,40 +282,92 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau var entry: ZipEntry? = zip.nextEntry val filesDir = ctx.filesDir ?: return@execute val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) - filesDir.deleteRecursively() - deviceProtectedFilesDir.deleteRecursively() + + // Targeted deletion based on selected categories + if (selectedCategories.contains(BackupCategory.LAYOUTS)) { + File(filesDir, "layouts").deleteRecursively() + } + if (selectedCategories.contains(BackupCategory.DICTIONARY_HISTORY)) { + File(filesDir, "dicts").deleteRecursively() + File(filesDir, "blacklists").deleteRecursively() + filesDir.listFiles()?.forEach { + if (it.name.startsWith("UserHistoryDictionary")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.THEME_APPEARANCE)) { + File(filesDir, "custom_font").delete() + File(filesDir, "custom_emoji_font").delete() + deviceProtectedFilesDir.listFiles()?.forEach { + if (it.name.startsWith("custom_background_image")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + ctx.deleteDatabase(Database.NAME) + } + LayoutUtilsCustom.onLayoutFileChanged() Settings.getInstance().stopListener() while (entry != null) { if (entry.name.startsWith("unprotected${File.separator}")) { val adjustedName = entry.name.substringAfter("unprotected${File.separator}") if (backupFilePatterns.any { adjustedName.matches(it) }) { - if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + val cat = getCategoryForFilePath(adjustedName) + if (cat == null || selectedCategories.contains(cat)) { + File(deviceProtectedFilesDir, adjustedName).delete() + if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + } } } } else if (backupFilePatterns.any { entry.name.matches(it) }) { - if (!restoreEntryToDir(zip, filesDir, entry.name)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + File(filesDir, entry.name).delete() + if (!restoreEntryToDir(zip, filesDir, entry.name)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + } } } else if (entry.name == Database.NAME) { - FileUtils.copyStreamToNewFile(zip, restoredDb) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + FileUtils.copyStreamToNewFile(zip, restoredDb) + } } else if (entry.name == PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val prefs = ctx.prefs() - prefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, prefs) + prefs.edit(commit = true) { + prefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, prefs, selectedCategories) } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val protectedPrefs = ctx.protectedPrefs() - protectedPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, protectedPrefs) + protectedPrefs.edit(commit = true) { + protectedPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, protectedPrefs, selectedCategories) } else { val auxPrefs = auxiliaryPrefsToBackUp(ctx)[entry.name] if (auxPrefs != null) { - val prefLines = String(zip.readBytes()).split("\n") - auxPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, auxPrefs) + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + val prefLines = String(zip.readBytes()).split("\n") + auxPrefs.edit(commit = true) { + auxPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, auxPrefs, selectedCategories) + } } } zip.closeEntry() @@ -227,8 +375,9 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau } } } - - Database.copyFromDb(restoredDb, ctx) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + Database.copyFromDb(restoredDb, ctx) + } Handler(Looper.getMainLooper()).post { FeedbackManager.message(ctx, R.string.backup_restored) } @@ -277,22 +426,32 @@ private fun settingsToJsonStream(settings: Map, out: OutputStream out.write(Json.encodeToString(stringSets).toByteArray()) } -private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences): Boolean { +private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences, selectedCategories: Set): Boolean { val i = list.iterator() val e = prefs.edit() try { while (i.hasNext()) { when (i.next()) { - "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } - "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } - "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } - "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } - "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } - "string set settings" -> Json.decodeFromString>>(i.next()).forEach { e.putStringSet(it.key, it.value) } + "boolean settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putStringSet(it.key, it.value) } } } - // commit synchronously so that post-restore actions and a possible process kill - // (e.g. the user closing the app immediately after restore) don't lose data e.commit() return true } catch (e: Exception) { @@ -340,3 +499,70 @@ private val backupFilePatterns by lazy { listOf( "custom_font".toRegex(), "custom_emoji_font".toRegex(), ) } + +enum class BackupCategory { + LAYOUTS, + THEME_APPEARANCE, + DICTIONARY_HISTORY, + CLIPBOARD, + GENERAL_SETTINGS +} + +private fun getCategoryForPrefKey(key: String): BackupCategory { + if (key.startsWith("layout_")) return BackupCategory.LAYOUTS + + val themeKeys = setOf( + "theme_style", "icon_style", "theme_colors", "theme_colors_night", + "theme_key_borders", "theme_auto_day_night", "custom_icon_names", + "navbar_color", "font_scale", "emoji_font_scale", "narrow_key_gaps", + "narrow_key_gaps_level", "emoji_key_fit", "emoji_skin_tone", "space_bar_text" + ) + if (themeKeys.contains(key) + || key.startsWith("user_colors_") + || key.startsWith("user_all_colors_") + || key.startsWith("user_more_colors_") + || key.startsWith("keyboard_height_scale") + || key.startsWith("bottom_padding_scale") + || key.startsWith("side_padding_scale") + || key.startsWith("split_spacer_scale") + ) { + return BackupCategory.THEME_APPEARANCE + } + + val dictKeys = setOf( + "use_personalized_dicts", "block_potentially_offensive", "next_word_prediction", + "suggest_emojis", "inline_emoji_search", "show_emoji_descriptions", + "auto_correction", "more_auto_correction", "auto_correct_threshold", + "autocorrect_shortcuts", "backspace_reverts_autocorrect", "suggest_punctuation", + "add_to_personal_dictionary" + ) + if (dictKeys.contains(key)) return BackupCategory.DICTIONARY_HISTORY + + val clipboardKeys = setOf( + "enable_clipboard_history", "suggest_screenshots", "compress_screenshots", + "clipboard_history_retention_time", "clipboard_history_pinned_first", + "clipboard_fold_pinned", "clear_clipboard_icon" + ) + if (clipboardKeys.contains(key)) return BackupCategory.CLIPBOARD + + return BackupCategory.GENERAL_SETTINGS +} + +private fun getCategoryForFilePath(path: String): BackupCategory? { + if (path.startsWith("layouts${File.separator}") || path.contains("layouts/")) { + return BackupCategory.LAYOUTS + } + if (path.startsWith("custom_background_image") || path == "custom_font" || path == "custom_emoji_font" || path == FLOATING_KEYBOARD_PREFS_FILE_NAME) { + return BackupCategory.THEME_APPEARANCE + } + if (path.startsWith("dicts${File.separator}") || path.startsWith("dicts/") + || path.startsWith("blacklists${File.separator}") || path.startsWith("blacklists/") + || path.startsWith("UserHistoryDictionary") + ) { + return BackupCategory.DICTIONARY_HISTORY + } + if (path == Database.NAME) { + return BackupCategory.CLIPBOARD + } + return null +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt new file mode 100644 index 000000000..b6a4c454e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.preferences + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.settings.FeedbackManager +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import helium314.keyboard.settings.filePicker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +@Composable +fun LoadHandwritingPluginPreference( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onSuccess: (() -> Unit)? = null, +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + var isDownloading by rememberSaveable { mutableStateOf(false) } + var remoteVersion by remember { mutableStateOf(null) } + var updateAvailable by remember { mutableStateOf(false) } + var isCheckingUpdate by remember { mutableStateOf(false) } + + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + + val hasPlugin = HandwritingLoader.hasPlugin(ctx) + val localVersion = remember(hasPlugin) { HandwritingLoader.getPluginVersion(ctx) } + + LaunchedEffect(hasPlugin) { + isCheckingUpdate = true + scope.launch(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest") + val conn = url.openConnection() as HttpURLConnection + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().use { it.readText() } + val regex = "\"tag_name\"\\s*:\\s*\"([^\"]+)\"".toRegex() + val match = regex.find(response) + if (match != null) { + val tag = match.groupValues[1] + remoteVersion = tag + if (hasPlugin && localVersion != null) { + updateAvailable = isUpdateAvailable(localVersion, tag) + } + } + } + } catch (e: Exception) { + // ignore network errors + } finally { + isCheckingUpdate = false + } + } + } + + val launcher = filePicker { uri -> + val success = HandwritingLoader.importPlugin(ctx, uri) + showDialog = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + + fun startDownload() { + isDownloading = true + scope.launch(Dispatchers.IO) { + try { + val tag = remoteVersion ?: "latest" + val urlStr = if (tag == "latest") { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk" + } else { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/download/$tag/handwriting_plugin.apk" + } + var url = URL(urlStr) + var conn = url.openConnection() as HttpURLConnection + conn.instanceFollowRedirects = true + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + + var redirectConn = conn + var status = redirectConn.responseCode + var redirectCount = 0 + while ((status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) { + val newUrl = redirectConn.getHeaderField("Location") + redirectConn.disconnect() + val nextUrl = URL(newUrl) + redirectConn = nextUrl.openConnection() as HttpURLConnection + redirectConn.setRequestProperty("User-Agent", "HeliboardL") + redirectConn.connect() + status = redirectConn.responseCode + redirectCount++ + } + + if (status != HttpURLConnection.HTTP_OK) { + throw IOException("Server returned HTTP $status") + } + + val tempFile = File(ctx.cacheDir, "temp_handwriting_plugin.apk") + redirectConn.inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + redirectConn.disconnect() + + val success = HandwritingLoader.importPlugin(ctx, Uri.fromFile(tempFile)) + tempFile.delete() + + withContext(Dispatchers.Main) { + isDownloading = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + showDialog = false + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + isDownloading = false + Toast.makeText(ctx, "Download failed: ${e.localizedMessage}", Toast.LENGTH_LONG).show() + } + } + } + } + + Preference( + name = title, + description = summary, + icon = icon, + onClick = { showDialog = true } + ) + + if (showDialog) { + ConfirmationDialog( + onDismissRequest = { if (!isDownloading) showDialog = false }, + onConfirmed = { + if (!isDownloading) { + if (hasPlugin && !updateAvailable) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { + startDownload() + } + } + }, + confirmButtonText = when { + isDownloading -> "Downloading..." + hasPlugin && !updateAvailable -> stringResource(R.string.load_handwriting_plugin_button_delete) + hasPlugin && updateAvailable -> "Update" + else -> "Download" + }, + title = { Text(stringResource(R.string.load_handwriting_plugin)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val message = when { + hasPlugin && updateAvailable -> "An update is available for the handwriting plugin!\nLocal version: $localVersion\nLatest version: $remoteVersion\n\nDo you want to download and update?" + hasPlugin -> "Handwriting plugin is active (version $localVersion).\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + remoteVersion != null -> "Download the latest handwriting plugin (version $remoteVersion) from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + else -> "Download the handwriting plugin from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + } + Text(message) + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + } + }, + neutralButtonText = when { + isDownloading -> null + hasPlugin && updateAvailable -> "Delete" + hasPlugin -> null + else -> "Load from file" + }, + onNeutral = { + if (hasPlugin) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { + showDialog = false + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + try { + launcher.launch(intent) + } catch (e: Exception) { + // ignore + } + } + } + ) + } +} + +private fun isUpdateAvailable(local: String, remote: String): Boolean { + val cleanLocal = local.removePrefix("v").trim() + val cleanRemote = remote.removePrefix("v").trim() + if (cleanLocal == cleanRemote) return false + + val localParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() } + val remoteParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(localParts.size, remoteParts.size) + for (i in 0 until maxLength) { + val localPart = localParts.getOrElse(i) { 0 } + val remotePart = remoteParts.getOrElse(i) { 0 } + if (remotePart > localPart) return true + if (localPart > remotePart) return false + } + return false +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index f359f2eae..bc1cae203 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -79,6 +79,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { add(SettingsWithoutKey.HUGGINGFACE_TOKEN) add(SettingsWithoutKey.HUGGINGFACE_MODEL) add(SettingsWithoutKey.HUGGINGFACE_ENDPOINT) + add(SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS) add(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE) add(SettingsWithoutKey.TRANSLATE_HUGGINGFACE_MODEL) } @@ -95,6 +96,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { @Composable private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { val items = listOf( + SettingsWithoutKey.CUSTOM_AI_KEYS, SettingsWithoutKey.OFFLINE_MODEL_PATH, SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED ) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 133c5069f..6f03b8dcc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -70,7 +70,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text @Composable @@ -455,6 +454,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, + Setting(context, SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS, R.string.ai_allow_insecure_connections_title, R.string.ai_allow_insecure_connections_summary) { setting -> + SwitchPreference(setting, Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS) + }, Setting(context, SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { setting -> val ctx = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) } @@ -534,7 +536,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { Preference( name = it.title, description = it.description, @@ -547,12 +549,10 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( if (BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.OFFLINE_MODEL_PATH, R.string.offline_model_title, R.string.offline_model_summary) { setting -> val context = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(context) } - var encoderPath by remember { mutableStateOf(service.getModelPath()) } - var decoderPath by remember { mutableStateOf(service.getDecoderPath()) } - var tokenizerPath by remember { mutableStateOf(service.getTokenizerPath()) } + var modelPath by remember { mutableStateOf(service.getModelPath()) } - // Encoder file picker - val encoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + // GGUF Model file picker + val modelLauncher = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { @@ -562,77 +562,26 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( Log.e("AdvancedScreen", "Failed to take persistable permission", e) } service.setModelPath(it.toString()) - encoderPath = it.toString() - FeedbackManager.message(context, "Encoder selected") - } - } - - // Decoder file picker - val decoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setDecoderPath(it.toString()) - decoderPath = it.toString() - FeedbackManager.message(context, "Decoder selected") - } - } - - // Tokenizer file picker - val tokenizerLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setTokenizerPath(it.toString()) - tokenizerPath = it.toString() - FeedbackManager.message(context, "Tokenizer selected") + modelPath = it.toString() + FeedbackManager.message(context, "Model selected") } } androidx.compose.foundation.layout.Column { - // Encoder (required) - Preference( - name = "Encoder Model (.onnx)", - description = if (encoderPath != null) service.getModelName() else "Required - select encoder ONNX file", - onClick = { encoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Decoder (required for generation) - Preference( - name = "Decoder Model (.onnx)", - description = if (decoderPath != null) "Selected" else "Required - select decoder ONNX file", - onClick = { decoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Tokenizer (required for proper tokenization) Preference( - name = "Tokenizer (tokenizer.json)", - description = if (tokenizerPath != null) "Selected" else "Required - select tokenizer.json", - onClick = { tokenizerLauncher.launch(arrayOf("application/json", "*/*")) } + name = "GGUF Model (.gguf)", + description = if (modelPath != null) service.getModelName() else "Required - select local GGUF model file", + onClick = { modelLauncher.launch(arrayOf("application/octet-stream", "*/*")) } ) - if (encoderPath != null || decoderPath != null || tokenizerPath != null) { + if (modelPath != null) { Preference( - name = "Remove All Models", - description = "Unload models and free memory", + name = "Remove Model", + description = "Unload model and free memory", onClick = { service.unloadModel() service.setModelPath(null) - service.setDecoderPath(null) - service.setTokenizerPath(null) - encoderPath = null - decoderPath = null - tokenizerPath = null + modelPath = null } ) } @@ -657,7 +606,25 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( onClick = { showSystemPromptDialog = true } ) + var showTranslateSystemPromptDialog by remember { mutableStateOf(false) } + if (showTranslateSystemPromptDialog) { + TextInputDialog( + title = { Text("Translate System Instruction") }, + initialText = service.getTranslateSystemPrompt(), + checkTextValid = { true }, + onConfirmed = { + service.setTranslateSystemPrompt(it) + showTranslateSystemPromptDialog = false + }, + onDismissRequest = { showTranslateSystemPromptDialog = false } + ) + } + Preference( + name = "Translate Instruction", + description = service.getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: "Default", + onClick = { showTranslateSystemPromptDialog = true } + ) // Target Language for Translation val languageSetting = Setting(context, Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { } @@ -679,7 +646,48 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) ) + // Temperature + SliderPreference( + name = stringResource(R.string.offline_temp_title), + key = Settings.PREF_OFFLINE_TEMP, + default = Defaults.PREF_OFFLINE_TEMP, + range = 0.0f..2.0f, + description = { String.format("%.2f", it) } + ) + + // Top-P + SliderPreference( + name = stringResource(R.string.offline_top_p_title), + key = Settings.PREF_OFFLINE_TOP_P, + default = Defaults.PREF_OFFLINE_TOP_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + // Top-K + SliderPreference( + name = stringResource(R.string.offline_top_k_title), + key = Settings.PREF_OFFLINE_TOP_K, + default = Defaults.PREF_OFFLINE_TOP_K, + range = 1.0f..100.0f, + description = { it.toString() } + ) + + // Min-P + SliderPreference( + name = stringResource(R.string.offline_min_p_title), + key = Settings.PREF_OFFLINE_MIN_P, + default = Defaults.PREF_OFFLINE_MIN_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + + // Show Thinking + val showThinkingSetting = Setting(context, Settings.PREF_OFFLINE_SHOW_THINKING, R.string.offline_show_thinking_title, R.string.offline_show_thinking_summary) { } + SwitchPreference( + setting = showThinkingSetting, + default = Defaults.PREF_OFFLINE_SHOW_THINKING + ) val tokenEntries = context.resources.getStringArray(R.array.offline_max_tokens_entries) val tokenValues = context.resources.getStringArray(R.array.offline_max_tokens_values).map { it.toInt() } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt new file mode 100644 index 000000000..e363c7db1 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.settings.DropDownField +import helium314.keyboard.settings.SearchScreen +import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import java.io.File +import java.io.IOException +import java.util.Locale + +private data class BlockedWord(val word: String, val locale: Locale) + +private fun getBlacklistFile(context: Context, locale: Locale): File { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists()) dir.mkdirs() + return File(dir, "${locale.toLanguageTag()}.txt") +} + +private fun loadBlockedWords(context: Context): List { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists() || !dir.isDirectory) return emptyList() + val list = mutableListOf() + dir.listFiles()?.forEach { file -> + if (file.isFile && file.name.endsWith(".txt")) { + val localeTag = file.name.substringBefore(".txt") + val locale = Locale.forLanguageTag(localeTag) + try { + file.readLines().forEach { line -> + val trimmed = line.trim().lowercase(locale) + if (trimmed.isNotEmpty()) { + list.add(BlockedWord(trimmed, locale)) + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error reading blacklist file $file", e) + } + } + } + val uniqueList = list.distinct() + return uniqueList.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) +} + +private fun addBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) + try { + val existing = if (file.exists()) file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() else mutableSetOf() + if (existing.add(lowercaseWord)) { + file.writeText(existing.joinToString("\n") + "\n") + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error adding word to blacklist", e) + } +} + +private fun removeBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) + try { + if (file.exists()) { + val existing = file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() + if (existing.remove(lowercaseWord)) { + if (existing.isEmpty()) { + file.delete() + } else { + file.writeText(existing.joinToString("\n") + "\n") + } + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error removing word from blacklist", e) + } +} + +private fun notifyKeyboardToReload() { + KeyboardSwitcher.getInstance().getLatinIME()?.getDictionaryFacilitator()?.reloadBlacklist() +} + +@Composable +fun BlockedWordsScreen( + onClickBack: () -> Unit, +) { + val ctx = LocalContext.current + var refreshTrigger by remember { mutableStateOf(0) } + val blockedWords = remember(refreshTrigger) { loadBlockedWords(ctx) } + var selectedWord: BlockedWord? by remember { mutableStateOf(null) } + var showClearAllDialog by remember { mutableStateOf(false) } + + Box(Modifier.fillMaxSize()) { + SearchScreen( + onClickBack = onClickBack, + title = { Text(stringResource(R.string.edit_blocked_words)) }, + menu = listOf( + stringResource(R.string.clear_all) to { showClearAllDialog = true } + ), + filteredItems = { term -> + blockedWords.filter { it.word.startsWith(term, true) } + }, + itemContent = { item -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedWord = item } + .padding(vertical = 6.dp, horizontal = 16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.word, style = MaterialTheme.typography.bodyLarge) + Text( + item.locale.getLocaleDisplayNameForUserDictSettings(ctx), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.IconButton( + onClick = { + removeBlockedWord(ctx, item.word, item.locale) + notifyKeyboardToReload() + refreshTrigger++ + } + ) { + Icon( + painter = painterResource(R.drawable.ic_bin), + contentDescription = stringResource(R.string.delete) + ) + } + } + } + ) + ExtendedFloatingActionButton( + onClick = { selectedWord = BlockedWord("", getSortedDictionaryLocales().firstOrNull() ?: Locale.getDefault()) }, + text = { Text(stringResource(R.string.add_blocked_word)) }, + icon = { Icon(painter = painterResource(R.drawable.ic_plus), stringResource(R.string.add_blocked_word)) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(all = 12.dp) + .then(Modifier.safeDrawingPadding()) + ) + } + + if (selectedWord != null) { + EditBlockedWordDialog(selectedWord!!, onDismissRequest = { + selectedWord = null + refreshTrigger++ + }) + } + + if (showClearAllDialog) { + ConfirmationDialog( + onDismissRequest = { showClearAllDialog = false }, + onConfirmed = { + showClearAllDialog = false + val dir = File(ctx.filesDir, "blacklists") + if (dir.exists() && dir.isDirectory) { + dir.listFiles()?.forEach { it.delete() } + } + notifyKeyboardToReload() + refreshTrigger++ + }, + content = { Text(stringResource(R.string.clear_all_blocked_words_confirmation)) } + ) + } +} + +@Composable +private fun EditBlockedWordDialog( + blockedWord: BlockedWord, + onDismissRequest: () -> Unit +) { + val ctx = LocalContext.current + val focusRequester = remember { FocusRequester() } + var wordText by remember { mutableStateOf(blockedWord.word) } + var wordLocale by remember { mutableStateOf(blockedWord.locale) } + + val localesList = remember { getSortedDictionaryLocales().toList() } + + val alreadyExists = remember(wordText, wordLocale) { + if (wordText.isBlank()) false + else { + val file = File(ctx.filesDir, "blacklists/${wordLocale.toLanguageTag()}.txt") + if (file.exists()) { + val cleanLower = wordText.trim().lowercase(wordLocale) + file.readLines().map { it.trim().lowercase(wordLocale) }.contains(cleanLower) + } else false + } + } + + val isNew = blockedWord.word.isEmpty() + val isSaveEnabled = wordText.isNotBlank() && (!alreadyExists || (!isNew && wordText.trim().lowercase(wordLocale) == blockedWord.word.lowercase(blockedWord.locale) && wordLocale == blockedWord.locale)) + + fun save() { + if (wordText.isNotBlank()) { + val cleanWord = wordText.trim() + if (!isNew && (blockedWord.word.lowercase(blockedWord.locale) != cleanWord.lowercase(wordLocale) || blockedWord.locale != wordLocale)) { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + } + addBlockedWord(ctx, cleanWord, wordLocale) + notifyKeyboardToReload() + } + } + + ThreeButtonAlertDialog( + onDismissRequest = onDismissRequest, + onConfirmed = { + save() + onDismissRequest() + }, + checkOk = { isSaveEnabled }, + confirmButtonText = stringResource(R.string.save), + neutralButtonText = if (isNew) null else stringResource(R.string.delete), + onNeutral = { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + notifyKeyboardToReload() + onDismissRequest() + }, + title = { + Text(if (isNew) stringResource(R.string.add_blocked_word) else stringResource(R.string.edit_blocked_words)) + }, + content = { + LaunchedEffect(blockedWord) { + if (isNew) { + focusRequester.requestFocus() + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = wordText, + onValueChange = { wordText = it }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + singleLine = true, + label = { Text("Word") }, + keyboardActions = KeyboardActions { + if (isSaveEnabled) { + save() + onDismissRequest() + } + } + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.user_dict_settings_add_locale_option_name), Modifier.fillMaxWidth(0.3f)) + DropDownField( + items = localesList, + selectedItem = wordLocale, + onSelected = { wordLocale = it }, + ) { + Text(it.getLocaleDisplayNameForUserDictSettings(ctx)) + } + } + if (alreadyExists && (isNew || wordText != blockedWord.word || wordLocale != blockedWord.locale)) { + Text( + stringResource(R.string.blocked_word_already_present), + color = MaterialTheme.colorScheme.error + ) + } + } + } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index 031b6116f..cdac0851d 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -64,7 +64,9 @@ fun DictionaryScreen( val enabledLanguages = SubtypeSettings.getEnabledSubtypes(true).map { it.locale().language } val cachedDictFolders = DictionaryInfoUtils.getCacheDirectories(ctx).map { it.name } val comparer = compareBy({ it.language !in enabledLanguages }, { it.toLanguageTag() !in cachedDictFolders }, { it.displayName }) - val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx).sortedWith(comparer) + val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx) + .filter { it.language != SubtypeLocaleUtils.NO_LANGUAGE } + .sortedWith(comparer) var selectedLocale: Locale? by remember { mutableStateOf(null) } var showAddDictDialog by remember { mutableStateOf(false) } val dictPicker = dictionaryFilePicker(selectedLocale) @@ -115,6 +117,23 @@ fun DictionaryScreen( } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocked Words Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) } + ) { + Text( + stringResource(R.string.edit_blocked_words), + style = MaterialTheme.typography.titleMedium + ) + NextScreenIcon() + } + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocklist Entry Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt index d5fc23b69..3c0d9fef8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -27,9 +27,16 @@ import helium314.keyboard.settings.NextScreenIcon import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.preferences.LoadGestureLibPreference import helium314.keyboard.settings.preferences.LoadEmojiLibPreference +import helium314.keyboard.settings.preferences.LoadHandwritingPluginPreference +import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.common.Links import helium314.keyboard.settings.preferences.Preference import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf @Composable fun LibrariesHubScreen( @@ -88,6 +95,17 @@ fun LibrariesHubScreen( icon = R.drawable.ic_emoji_smileys_emotion ) + // Handwriting Input Plugin + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } + LoadHandwritingPluginPreference( + title = stringResource(R.string.libraries_hub_handwriting_title), + summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), + icon = R.drawable.ic_edit, + onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } + ) + } + // Documentation & Features val uriHandler = LocalUriHandler.current Preference( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt index 7559a50d6..a1c9138e9 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt @@ -134,7 +134,10 @@ private fun EditWordDialog(word: Word, locale: Locale?, onDismissRequest: () -> val focusRequester = remember { FocusRequester() } var newWord by remember { mutableStateOf(word) } var newLocale by remember { mutableStateOf(locale) } - val wordValid = (newWord.word == word.word && locale == newLocale) || !doesWordExist(newWord.word, newLocale, ctx) + val identityUnchanged = newWord.word == word.word + && (newWord.shortcut.isNullOrEmpty() && word.shortcut.isNullOrEmpty() || newWord.shortcut == word.shortcut) + && locale == newLocale + val wordValid = identityUnchanged || !doesWordExist(newWord.word, newWord.shortcut, newLocale, ctx) fun save() { if (newWord != word || locale != newLocale) { deleteWord(word, locale, ctx.contentResolver) @@ -257,18 +260,27 @@ private fun deleteWord(wordDetails: Word, locale: Locale?, resolver: ContentReso } } -private fun doesWordExist(word: String, locale: Locale?, context: Context): Boolean { +private fun doesWordExist(word: String, shortcut: String?, locale: Locale?, context: Context): Boolean { val hasWordProjection = arrayOf(UserDictionary.Words.WORD, UserDictionary.Words.LOCALE) val select: String - val selectArgs: Array? + val selectArgs: Array if (locale == null) { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null" - selectArgs = arrayOf(word) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, shortcut) + } } else { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=?" - // requires use of locale string (as opposed to more useful language tag) for interaction with Android system - selectArgs = arrayOf(word, locale.toString()) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word, locale.toString()) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, locale.toString(), shortcut) + } } val cursor = context.contentResolver.query(UserDictionary.Words.CONTENT_URI, hasWordProjection, select, selectArgs, null) cursor.use { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index 68f842b42..59dbce68d 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -95,6 +95,7 @@ fun TextCorrectionScreen( Settings.PREF_SUGGEST_SCREENSHOTS, if (prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS)) Settings.PREF_COMPRESS_SCREENSHOTS else null, + Settings.PREF_AUTO_READ_OTP, Settings.PREF_USE_CONTACTS, Settings.PREF_USE_APPS ) @@ -254,6 +255,25 @@ fun createCorrectionSettings(context: Context) = listOf( ) { SwitchPreference(it, Defaults.PREF_COMPRESS_SCREENSHOTS) }, + Setting(context, Settings.PREF_AUTO_READ_OTP, + R.string.auto_read_otp, R.string.auto_read_otp_summary + ) { setting -> + val activity = LocalContext.current.getActivity() ?: return@Setting + var granted by remember { mutableStateOf(PermissionsUtil.checkAllPermissionsGranted(activity, Manifest.permission.RECEIVE_SMS)) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + granted = it + if (granted) + activity.prefs().edit { putBoolean(setting.key, true) } + } + SwitchPreference(setting, Defaults.PREF_AUTO_READ_OTP, + allowCheckedChange = { + if (it && !granted) { + launcher.launch(Manifest.permission.RECEIVE_SMS) + false + } else true + } + ) + }, Setting(context, Settings.PREF_USE_CONTACTS, R.string.use_contacts_dict, R.string.use_contacts_dict_summary ) { setting -> diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index e8a1a8014..5896fdd11 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -79,6 +79,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { mutableStateOf(TextExpanderUtils.isEnabled(context)) } + var isImmediateEnabled by remember { + mutableStateOf(TextExpanderUtils.isImmediateEnabled(context)) + } + var shortcutsMap by remember { mutableStateOf(TextExpanderUtils.getShortcuts(context)) } @@ -89,6 +93,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var editingShortcut by remember { mutableStateOf("") } var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } + var editingIsRegex by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { SearchScreen( @@ -104,7 +109,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { filteredItems = { term -> shortcutsMap.entries .filter { (shortcut, template) -> - shortcut.contains(term, ignoreCase = true) || + val displayShortcut = if (shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX)) { + shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) + } else shortcut + displayShortcut.contains(term, ignoreCase = true) || template.contains(term, ignoreCase = true) } .map { Pair(it.key, it.value) } @@ -116,9 +124,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -305,6 +315,15 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { onCheckedChange = { isExpanderEnabled = it } ) + SwitchPreference( + name = "Expand immediately", + key = TextExpanderUtils.PREF_IMMEDIATE, + default = false, + description = "Expand shortcuts immediately without pressing space.", + enabled = isExpanderEnabled, + onCheckedChange = { isImmediateEnabled = it } + ) + // 2. Custom Prefix Configuration OutlinedTextField( value = prefixText, @@ -382,9 +401,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -409,6 +430,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { editingShortcut = "" editingTemplate = TextFieldValue("") originalShortcutToEdit = null + editingIsRegex = false showAddDialog = true }, text = { Text("Add Shortcut") }, @@ -425,20 +447,28 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { if (showAddDialog) { val focusRequester = remember { FocusRequester() } val isEditMode = originalShortcutToEdit != null + val isRegexValid = remember(editingShortcut, editingIsRegex) { + !editingIsRegex || runCatching { Regex(editingShortcut.trim()) }.isSuccess + } ThreeButtonAlertDialog( onDismissRequest = { showAddDialog = false }, onConfirmed = { val updated = shortcutsMap.toMutableMap() - if (isEditMode && originalShortcutToEdit != editingShortcut) { + if (isEditMode) { updated.remove(originalShortcutToEdit) } - updated[editingShortcut.trim()] = editingTemplate.text + val key = if (editingIsRegex) { + TextExpanderUtils.REGEX_PREFIX + editingShortcut.trim() + } else { + editingShortcut.trim() + } + updated[key] = editingTemplate.text shortcutsMap = updated TextExpanderUtils.saveShortcuts(context, updated) showAddDialog = false }, - checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() }, + checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() && isRegexValid }, confirmButtonText = if (isEditMode) "Save" else "Add", neutralButtonText = if (isEditMode) "Delete" else null, onNeutral = { @@ -460,13 +490,41 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { TextField( value = editingShortcut, - onValueChange = { editingShortcut = it.replace(" ", "") }, + onValueChange = { editingShortcut = if (editingIsRegex) it else it.replace(" ", "") }, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), singleLine = true, - label = { Text("Shortcut (e.g. 'brb', 'em')") } + label = { Text(if (editingIsRegex) "Regex Pattern (e.g. '(\\d+)usd')" else "Shortcut (e.g. 'brb', 'em')") } ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Regular Expression", + style = MaterialTheme.typography.bodyMedium + ) + androidx.compose.material3.Switch( + checked = editingIsRegex, + onCheckedChange = { checked -> + editingIsRegex = checked + if (!checked) { + editingShortcut = editingShortcut.replace(" ", "") + } + } + ) + } + + if (editingIsRegex && !isRegexValid) { + Text( + text = "⚠️ Invalid regular expression pattern", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } OutlinedTextField( value = editingTemplate, @@ -538,6 +596,9 @@ private fun ShortcutItem( onEdit: () -> Unit, onDelete: () -> Unit ) { + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + val displayShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -556,7 +617,10 @@ private fun ShortcutItem( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) @@ -564,13 +628,28 @@ private fun ShortcutItem( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - text = "$prefix$shortcut", + text = if (isRegex) displayShortcut else "$prefix$displayShortcut", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace ) } + if (isRegex) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "Regex", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.Bold + ) + } + } } Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index 0b6d128d8..215331ce8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,6 +39,7 @@ import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.getStringResourceOrName import helium314.keyboard.latin.utils.prefs +import helium314.keyboard.latin.utils.upgradeToolbarPrefs import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.Setting import helium314.keyboard.settings.SettingsActivity @@ -54,8 +56,12 @@ import helium314.keyboard.settings.previewDark fun ToolbarScreen( onClickBack: () -> Unit, ) { - val prefs = LocalContext.current.prefs() - val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() + val context = LocalContext.current + val prefs = context.prefs() + LaunchedEffect(Unit) { + upgradeToolbarPrefs(prefs) + } + val b = (context.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() if ((b?.value ?: 0) < 0) Log.v("irrelevant", "stupid way to trigger recomposition on preference change") val toolbarMode = Settings.readToolbarMode(prefs) @@ -93,7 +99,8 @@ fun createToolbarSettings(context: Context): List { val filter = { name: String -> val lowerName = name.lowercase() when { - lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline" || BuildConfig.FLAVOR == "standardOptimised" + lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/app/src/main/jni/CMakeLists.txt b/app/src/main/jni/CMakeLists.txt index 5068fbf6d..dd6e6e64f 100644 --- a/app/src/main/jni/CMakeLists.txt +++ b/app/src/main/jni/CMakeLists.txt @@ -55,6 +55,10 @@ target_include_directories(latinime_host_unittests PRIVATE ${SRC_DIR} ${TEST_DIR target_compile_options(latinime_host_unittests PRIVATE -include ${CMAKE_CURRENT_SOURCE_DIR}/host_test_compat.h -Wno-unused-parameter -Wno-unused-function) +# Expose the on-disk fixture directory to the replay tests so they can load +# JSON fixtures by path (TraceFixtureParserTest.LoadsHelloQwertyFromFile). +target_compile_definitions(latinime_host_unittests PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/replay/fixtures") target_link_libraries(latinime_host_unittests PRIVATE gtest gtest_main) enable_testing() diff --git a/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp b/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp index 933a5e145..3195a2450 100644 --- a/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp +++ b/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp @@ -49,6 +49,24 @@ static AK_FORCE_INLINE void safeGetOrFillZeroFloatArrayRegion(JNIEnv *env, jfloa } } +static AK_FORCE_INLINE void copyOrFillZeroIntArray(const int *const source, + const int len, int *const buffer) { + if (source && buffer) { + memcpy(buffer, source, len * sizeof(buffer[0])); + } else if (buffer) { + memset(buffer, 0, len * sizeof(buffer[0])); + } +} + +static AK_FORCE_INLINE void copyOrFillZeroFloatArray(const float *const source, + const int len, float *const buffer) { + if (source && buffer) { + memcpy(buffer, source, len * sizeof(buffer[0])); + } else if (buffer) { + memset(buffer, 0, len * sizeof(buffer[0])); + } +} + ProximityInfo::ProximityInfo(JNIEnv *env, const int keyboardWidth, const int keyboardHeight, const int gridWidth, const int gridHeight, const int mostCommonKeyWidth, const int mostCommonKeyHeight, const jintArray proximityChars, const int keyCount, @@ -95,6 +113,50 @@ ProximityInfo::ProximityInfo(JNIEnv *env, const int keyboardWidth, const int key initializeG(); } +ProximityInfo::ProximityInfo(const int keyboardWidth, const int keyboardHeight, + const int gridWidth, const int gridHeight, const int mostCommonKeyWidth, + const int mostCommonKeyHeight, const int *const proximityChars, + const int proximityCharsLength, const int keyCount, + const int *const keyXCoordinates, const int *const keyYCoordinates, + const int *const keyWidths, const int *const keyHeights, + const int *const keyCharCodes, const float *const sweetSpotCenterXs, + const float *const sweetSpotCenterYs, const float *const sweetSpotRadii) + : GRID_WIDTH(gridWidth), GRID_HEIGHT(gridHeight), MOST_COMMON_KEY_WIDTH(mostCommonKeyWidth), + MOST_COMMON_KEY_WIDTH_SQUARE(mostCommonKeyWidth * mostCommonKeyWidth), + NORMALIZED_SQUARED_MOST_COMMON_KEY_HYPOTENUSE(1.0f + + GeometryUtils::SQUARE_FLOAT(static_cast(mostCommonKeyHeight) / + static_cast(mostCommonKeyWidth))), + CELL_WIDTH((keyboardWidth + gridWidth - 1) / gridWidth), + CELL_HEIGHT((keyboardHeight + gridHeight - 1) / gridHeight), + KEY_COUNT(std::min(keyCount, MAX_KEY_COUNT_IN_A_KEYBOARD)), + KEYBOARD_WIDTH(keyboardWidth), KEYBOARD_HEIGHT(keyboardHeight), + KEYBOARD_HYPOTENUSE(hypotf(KEYBOARD_WIDTH, KEYBOARD_HEIGHT)), + HAS_TOUCH_POSITION_CORRECTION_DATA(keyCount > 0 && keyXCoordinates && keyYCoordinates + && keyWidths && keyHeights && keyCharCodes && sweetSpotCenterXs + && sweetSpotCenterYs && sweetSpotRadii), + mProximityCharsArray(new int[GRID_WIDTH * GRID_HEIGHT * MAX_PROXIMITY_CHARS_SIZE + /* proximityCharsLength */]), + mLowerCodePointToKeyMap() { + const int expectedLength = GRID_WIDTH * GRID_HEIGHT * MAX_PROXIMITY_CHARS_SIZE; + if (proximityCharsLength != expectedLength) { + AKLOGE("Invalid host proximityCharsLength: %d expected: %d", proximityCharsLength, + expectedLength); + ASSERT(false); + memset(mProximityCharsArray, 0, expectedLength * sizeof(mProximityCharsArray[0])); + } else { + copyOrFillZeroIntArray(proximityChars, expectedLength, mProximityCharsArray); + } + copyOrFillZeroIntArray(keyXCoordinates, KEY_COUNT, mKeyXCoordinates); + copyOrFillZeroIntArray(keyYCoordinates, KEY_COUNT, mKeyYCoordinates); + copyOrFillZeroIntArray(keyWidths, KEY_COUNT, mKeyWidths); + copyOrFillZeroIntArray(keyHeights, KEY_COUNT, mKeyHeights); + copyOrFillZeroIntArray(keyCharCodes, KEY_COUNT, mKeyCodePoints); + copyOrFillZeroFloatArray(sweetSpotCenterXs, KEY_COUNT, mSweetSpotCenterXs); + copyOrFillZeroFloatArray(sweetSpotCenterYs, KEY_COUNT, mSweetSpotCenterYs); + copyOrFillZeroFloatArray(sweetSpotRadii, KEY_COUNT, mSweetSpotRadii); + initializeG(); +} + ProximityInfo::~ProximityInfo() { delete[] mProximityCharsArray; } diff --git a/app/src/main/jni/src/suggest/core/layout/proximity_info.h b/app/src/main/jni/src/suggest/core/layout/proximity_info.h index f7c907697..d882e9dc2 100644 --- a/app/src/main/jni/src/suggest/core/layout/proximity_info.h +++ b/app/src/main/jni/src/suggest/core/layout/proximity_info.h @@ -35,6 +35,18 @@ class ProximityInfo { const jintArray keyYCoordinates, const jintArray keyWidths, const jintArray keyHeights, const jintArray keyCharCodes, const jfloatArray sweetSpotCenterXs, const jfloatArray sweetSpotCenterYs, const jfloatArray sweetSpotRadii); + + // Host-test constructor: same data as the JNI constructor, but passed as raw arrays so + // native unit tests can build ProximityInfo without a live JVM/JNIEnv. + ProximityInfo(const int keyboardWidth, const int keyboardHeight, + const int gridWidth, const int gridHeight, + const int mostCommonKeyWidth, const int mostCommonKeyHeight, + const int *const proximityChars, const int proximityCharsLength, + const int keyCount, const int *const keyXCoordinates, + const int *const keyYCoordinates, const int *const keyWidths, + const int *const keyHeights, const int *const keyCharCodes, + const float *const sweetSpotCenterXs, const float *const sweetSpotCenterYs, + const float *const sweetSpotRadii); ~ProximityInfo(); bool hasSpaceProximity(const int x, const int y) const; float getNormalizedSquaredDistanceFromCenterFloatG( diff --git a/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json b/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json new file mode 100644 index 000000000..8bdfa0516 --- /dev/null +++ b/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "createdAt": 1720000000000, + "keyboard": { + "width": 1080, + "height": 310, + "mainLayout": "qwerty", + "locale": "en-US" + }, + "committedWord": "hello", + "pointers": [ + {"id": 0, "x": 648, "y": 185, "t": 0}, + {"id": 0, "x": 590, "y": 168, "t": 48}, + {"id": 0, "x": 450, "y": 135, "t": 98}, + {"id": 0, "x": 340, "y": 110, "t": 148}, + {"id": 0, "x": 270, "y": 100, "t": 200}, + {"id": 0, "x": 370, "y": 120, "t": 258}, + {"id": 0, "x": 540, "y": 158, "t": 308}, + {"id": 0, "x": 660, "y": 185, "t": 358}, + {"id": 0, "x": 756, "y": 185, "t": 408}, + {"id": 0, "x": 810, "y": 185, "t": 450}, + {"id": 0, "x": 864, "y": 185, "t": 500}, + {"id": 0, "x": 864, "y": 184, "t": 552}, + {"id": 0, "x": 891, "y": 143, "t": 600}, + {"id": 0, "x": 918, "y": 100, "t": 648} + ] +} diff --git a/app/src/main/jni/tests/replay/gesture_replay_test.cpp b/app/src/main/jni/tests/replay/gesture_replay_test.cpp new file mode 100644 index 000000000..07f01a728 --- /dev/null +++ b/app/src/main/jni/tests/replay/gesture_replay_test.cpp @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Native gesture-replay harness — issue #78, deliverable 2. +// +// ============================================================================= +// ENABLED tests (TraceFixtureParserTest.*) +// Validate the JSON fixture loader end-to-end: parse an embedded literal and, +// when FIXTURE_DIR is defined, a file from the on-disk fixture directory. +// These always pass in ctest without any runtime assets. +// +// DISABLED tests (DISABLED_GestureReplayTest.*) +// Compile-checked stubs that would feed a loaded trace through the latinime +// gesture recognizer. Disabled because the open-source tree does NOT contain +// a gesture suggest policy implementation: it only has GestureSuggestPolicyFactory, +// whose factory method is null in the host build. Dictionary::getSuggestions(...) +// with IS_GESTURE therefore constructs Suggest with a null policy and would crash +// at TRAVERSAL->getMaxSpatialDistance(). +// +// What is proven here: +// a) TraceRecorder-style fixtures parse into the exact x/y/time/pointer arrays +// expected by Dictionary::getSuggestions. +// b) ProximityInfo can now be constructed on the host from raw arrays — no JNIEnv +// required — and QWERTY key lookup works. +// +// Next concrete step to get real replay assertions: +// provide an open/host-buildable GestureSuggestPolicy implementation (e.g. the +// future NLnet recognizer) or a test double with the same policy interface. Once +// GestureSuggestPolicyFactory returns a real policy, this scaffold can wire the +// fixture + ProximityInfo + Dictionary together and assert the suggestion. +// ============================================================================= + +#include +#include +#include + +#include "replay/trace_fixture.h" +#include "suggest/core/layout/proximity_info.h" + +namespace latinime { +namespace replay { +namespace { + +// ---- Embedded fixture (TraceRecorder schema v1, word "hello") --------------- +// +// Recorded on a 1080×310 px QWERTY keyboard (en-US). +// Key-centre estimates (px): +// h ≈ (648, 185) e ≈ (270, 100) l ≈ (864, 185) o ≈ (918, 100) +// +static constexpr const char kHelloQwertyJson[] = + R"json({"version":1,"createdAt":1720000000000,)json" + R"json("keyboard":{"width":1080,"height":310,)json" + R"json("mainLayout":"qwerty","locale":"en-US"},)json" + R"json("committedWord":"hello",)json" + R"json("pointers":[)json" + R"json({"id":0,"x":648,"y":185,"t":0},)json" + R"json({"id":0,"x":590,"y":168,"t":48},)json" + R"json({"id":0,"x":450,"y":135,"t":98},)json" + R"json({"id":0,"x":340,"y":110,"t":148},)json" + R"json({"id":0,"x":270,"y":100,"t":200},)json" + R"json({"id":0,"x":370,"y":120,"t":258},)json" + R"json({"id":0,"x":540,"y":158,"t":308},)json" + R"json({"id":0,"x":660,"y":185,"t":358},)json" + R"json({"id":0,"x":756,"y":185,"t":408},)json" + R"json({"id":0,"x":810,"y":185,"t":450},)json" + R"json({"id":0,"x":864,"y":185,"t":500},)json" + R"json({"id":0,"x":864,"y":184,"t":552},)json" + R"json({"id":0,"x":891,"y":143,"t":600},)json" + R"json({"id":0,"x":918,"y":100,"t":648})json" + R"json(]})json"; + +// ============================================================================= +// TraceFixtureParserTest — enabled, runs in ctest +// ============================================================================= + +TEST(TraceFixtureParserTest, ParsesVersionAndMetadata) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(1, fix.version); + EXPECT_EQ(1720000000000LL, fix.createdAt); + EXPECT_EQ("hello", fix.committedWord); +} + +TEST(TraceFixtureParserTest, ParsesKeyboardGeometry) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(1080, fix.keyboard.width); + EXPECT_EQ(310, fix.keyboard.height); + EXPECT_EQ("qwerty", fix.keyboard.mainLayout); + EXPECT_EQ("en-US", fix.keyboard.locale); +} + +TEST(TraceFixtureParserTest, ParsesPointerCount) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(14, fix.inputSize()); + ASSERT_EQ(14u, fix.pointers.size()); +} + +TEST(TraceFixtureParserTest, ParsesFirstAndLastPointerSample) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + + // First sample — starts over 'h' + EXPECT_EQ(0, fix.pointers.front().id); + EXPECT_EQ(648, fix.pointers.front().x); + EXPECT_EQ(185, fix.pointers.front().y); + EXPECT_EQ(0, fix.pointers.front().t); + + // Last sample — ends over 'o' + EXPECT_EQ(0, fix.pointers.back().id); + EXPECT_EQ(918, fix.pointers.back().x); + EXPECT_EQ(100, fix.pointers.back().y); + EXPECT_EQ(648, fix.pointers.back().t); +} + +TEST(TraceFixtureParserTest, AccessorArraysMatchPointers) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + const auto xs = fix.xCoordinates(); + const auto ys = fix.yCoordinates(); + const auto ts = fix.times(); + const auto ids = fix.pointerIds(); + + ASSERT_EQ(fix.pointers.size(), xs.size()); + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { + EXPECT_EQ(fix.pointers[i].x, xs[i]) << "xs mismatch at " << i; + EXPECT_EQ(fix.pointers[i].y, ys[i]) << "ys mismatch at " << i; + EXPECT_EQ(fix.pointers[i].t, ts[i]) << "ts mismatch at " << i; + EXPECT_EQ(fix.pointers[i].id, ids[i]) << "ids mismatch at " << i; + } +} + +TEST(TraceFixtureParserTest, TimestampsAreMonotonicallyNonDecreasing) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + for (std::size_t i = 1; i < fix.pointers.size(); ++i) { + EXPECT_GE(fix.pointers[i].t, fix.pointers[i - 1].t) + << "timestamp regression at index " << i; + } +} + +TEST(TraceFixtureParserTest, AllPointersWithinKeyboardBounds) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { + EXPECT_GE(fix.pointers[i].x, 0) << "x < 0 at " << i; + EXPECT_LE(fix.pointers[i].x, fix.keyboard.width) << "x > width at " << i; + EXPECT_GE(fix.pointers[i].y, 0) << "y < 0 at " << i; + EXPECT_LE(fix.pointers[i].y, fix.keyboard.height) << "y > height at " << i; + } +} + +TEST(TraceFixtureParserTest, ParsesJsonEscapedString) { + // Verify the parser handles escaped double-quotes and backslashes in strings. + const std::string json = + R"({"version":1,"createdAt":0,)" + R"("keyboard":{"width":100,"height":100,"mainLayout":"a\"b","locale":"c\\d"},)" + R"("committedWord":"w\"x","pointers":[]})"; + const TraceFixture fix = parseFixture(json); + EXPECT_EQ("a\"b", fix.keyboard.mainLayout); + EXPECT_EQ("c\\d", fix.keyboard.locale); + EXPECT_EQ("w\"x", fix.committedWord); +} + +TEST(TraceFixtureParserTest, ParsesEmptyPointerArray) { + const std::string json = + R"({"version":1,"createdAt":0,)" + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" + R"("committedWord":"","pointers":[]})"; + const TraceFixture fix = parseFixture(json); + EXPECT_EQ(0, fix.inputSize()); + EXPECT_TRUE(fix.pointers.empty()); +} + +TEST(TraceFixtureParserTest, ToleratesUnknownTopLevelKeys) { + // Ensures forward-compatibility: extra keys are silently skipped. + const std::string json = + R"({"version":1,"createdAt":0,"newField":{"nested":42},)" + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" + R"("committedWord":"hi","pointers":[]})"; + EXPECT_NO_THROW({ + const TraceFixture fix = parseFixture(json); + EXPECT_EQ("hi", fix.committedWord); + }); +} + +// File-based test: only compiled when FIXTURE_DIR is passed by CMake. +#if defined(FIXTURE_DIR) +TEST(TraceFixtureParserTest, LoadsHelloQwertyFromFile) { + const std::string path = std::string(FIXTURE_DIR) + "/hello_qwerty.json"; + TraceFixture fix; + ASSERT_NO_THROW({ fix = loadFixture(path); }) << "path: " << path; + EXPECT_EQ(1, fix.version); + EXPECT_EQ("hello", fix.committedWord); + EXPECT_EQ(1080, fix.keyboard.width); + EXPECT_GT(fix.inputSize(), 0); +} +#endif + +static std::vector buildEmptyProximityChars(const int gridWidth, const int gridHeight) { + return std::vector(gridWidth * gridHeight * MAX_PROXIMITY_CHARS_SIZE, NOT_A_CODE_POINT); +} + +TEST(GestureReplayHostSeamTest, BuildsProximityInfoWithoutJNI) { + // Minimal QWERTY row geometry sufficient to prove the replay harness can construct + // ProximityInfo from raw host arrays. The full recognizer assertion is still blocked on + // a binary .dict asset; this removes the JNIEnv blocker. + constexpr int keyboardWidth = 1080; + constexpr int keyboardHeight = 310; + constexpr int gridWidth = 10; + constexpr int gridHeight = 5; + constexpr int keyWidth = 108; + constexpr int keyHeight = 90; + const char *letters = "qwertyuiopasdfghjklzxcvbnm"; + constexpr int keyCount = 26; + + int xs[keyCount]; + int ys[keyCount]; + int widths[keyCount]; + int heights[keyCount]; + int codes[keyCount]; + float sweetXs[keyCount]; + float sweetYs[keyCount]; + float radii[keyCount]; + + for (int i = 0; i < keyCount; ++i) { + const int row = i < 10 ? 0 : (i < 19 ? 1 : 2); + const int col = i < 10 ? i : (i < 19 ? i - 10 : i - 19); + const int rowOffset = row == 0 ? 0 : (row == 1 ? keyWidth / 2 : keyWidth); + xs[i] = rowOffset + col * keyWidth; + ys[i] = row * keyHeight; + widths[i] = keyWidth; + heights[i] = keyHeight; + codes[i] = letters[i]; + sweetXs[i] = xs[i] + keyWidth / 2.0f; + sweetYs[i] = ys[i] + keyHeight / 2.0f; + radii[i] = keyWidth / 2.0f; + } + const std::vector proximityChars = buildEmptyProximityChars(gridWidth, gridHeight); + ProximityInfo info(keyboardWidth, keyboardHeight, gridWidth, gridHeight, keyWidth, keyHeight, + proximityChars.data(), static_cast(proximityChars.size()), keyCount, + xs, ys, widths, heights, codes, sweetXs, sweetYs, radii); + + EXPECT_EQ(keyCount, info.getKeyCount()); + EXPECT_TRUE(info.isCodePointOnKeyboard('h')); + EXPECT_TRUE(info.isCodePointOnKeyboard('e')); + EXPECT_TRUE(info.isCodePointOnKeyboard('l')); + EXPECT_TRUE(info.isCodePointOnKeyboard('o')); + EXPECT_FALSE(info.isCodePointOnKeyboard('#')); + EXPECT_EQ('h', info.getCodePointOf(info.getKeyIndexOf('h'))); +} + +TEST(DISABLED_GestureReplayTest, ReplayHelloQwerty) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + ASSERT_EQ("hello", fix.committedWord); + + // The host replay harness can now parse the trace and construct ProximityInfo without JNI. + // It still cannot call the actual gesture recognizer because this open-source tree has no + // GestureSuggestPolicy implementation; GestureSuggestPolicyFactory::getGestureSuggestPolicy() + // returns nullptr in host tests. Enabling this assertion requires an open/host-buildable + // policy implementation (or a test policy) first. +} + +} // namespace +} // namespace replay +} // namespace latinime diff --git a/app/src/main/jni/tests/replay/trace_fixture.h b/app/src/main/jni/tests/replay/trace_fixture.h new file mode 100644 index 000000000..eab55b7d3 --- /dev/null +++ b/app/src/main/jni/tests/replay/trace_fixture.h @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Fixture loader for native gesture-replay tests (issue #78, deliverable 2). +// +// Parses the TraceRecorder JSON schema (version 1) into plain C++ structs whose +// raw int arrays can be forwarded to the latinime recognizer APIs once the JNI +// blocker is lifted (see gesture_replay_test.cpp). +// +// No external dependencies: header-only, C++17, standard library only. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace latinime { +namespace replay { + +// ---- Data model ------------------------------------------------------------- + +struct KeyboardMeta { + int width = 0; + int height = 0; + std::string mainLayout; + std::string locale; +}; + +struct PointerSample { + int id = 0; + int x = 0; + int y = 0; + int t = 0; +}; + +struct TraceFixture { + int version = 0; + long long createdAt = 0; + KeyboardMeta keyboard; + std::string committedWord; + std::vector pointers; + + // Convenience accessors — return plain int vectors suitable for + // Dictionary::getSuggestions / Suggest::getSuggestions once those are + // reachable in the host build (see DISABLED_ tests). + std::vector xCoordinates() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].x; + return v; + } + std::vector yCoordinates() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].y; + return v; + } + std::vector times() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].t; + return v; + } + std::vector pointerIds() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].id; + return v; + } + int inputSize() const { return static_cast(pointers.size()); } +}; + +// ---- Minimal JSON parser ---------------------------------------------------- +// Only handles the exact TraceRecorder schema; not a general-purpose parser. + +namespace detail { + +struct Parser { + const char *p; + const char *end; + + explicit Parser(const std::string &s) + : p(s.data()), end(s.data() + s.size()) {} + + void skipWs() { + while (p < end && std::isspace(static_cast(*p))) ++p; + } + + bool peek(char c) { skipWs(); return p < end && *p == c; } + + bool consume(char c) { + if (!peek(c)) return false; + ++p; + return true; + } + + void expect(char c) { + if (!consume(c)) { + throw std::runtime_error( + std::string("TraceFixture JSON: expected '") + c + + "', got '" + (p < end ? std::string(1, *p) : "EOF") + "'"); + } + } + + std::string parseString() { + expect('"'); + std::string out; + while (p < end && *p != '"') { + if (*p == '\\') { + ++p; + if (p < end) { + switch (*p) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + default: out += *p; break; + } + ++p; + } + } else { + out += *p++; + } + } + expect('"'); + return out; + } + + long long parseInt() { + skipWs(); + bool neg = false; + if (p < end && *p == '-') { neg = true; ++p; } + if (p >= end || !std::isdigit(static_cast(*p))) + throw std::runtime_error("TraceFixture JSON: expected digit"); + long long v = 0; + while (p < end && std::isdigit(static_cast(*p))) + v = v * 10 + (*p++ - '0'); + return neg ? -v : v; + } + + // parseObject: for each key, calls f(key) and f must consume the value. + template + void parseObject(F f) { + expect('{'); + skipWs(); + if (peek('}')) { ++p; return; } + do { + skipWs(); + std::string key = parseString(); + skipWs(); + expect(':'); + f(key); + skipWs(); + } while (consume(',')); + expect('}'); + } + + // parseArray: calls f() for each element; f must consume the element. + template + void parseArray(F f) { + expect('['); + skipWs(); + if (peek(']')) { ++p; return; } + do { + skipWs(); + f(); + skipWs(); + } while (consume(',')); + expect(']'); + } + + void skipValue() { + skipWs(); + if (peek('"')) { parseString(); return; } + if (peek('{')) { + parseObject([this](const std::string &) { skipValue(); }); + return; + } + if (peek('[')) { + parseArray([this]() { skipValue(); }); + return; + } + // number, true, false, null + while (p < end && *p != ',' && *p != '}' && *p != ']' && + !std::isspace(static_cast(*p))) + ++p; + } +}; + +} // namespace detail + +// Parse a JSON string conforming to the TraceRecorder schema (version 1). +// Throws std::runtime_error on malformed input. +inline TraceFixture parseFixture(const std::string &json) { + detail::Parser par(json); + TraceFixture fix; + + par.parseObject([&](const std::string &key) { + if (key == "version") { + fix.version = static_cast(par.parseInt()); + } else if (key == "createdAt") { + fix.createdAt = par.parseInt(); + } else if (key == "committedWord") { + fix.committedWord = par.parseString(); + } else if (key == "keyboard") { + par.parseObject([&](const std::string &kk) { + if (kk == "width") fix.keyboard.width = static_cast(par.parseInt()); + else if (kk == "height") fix.keyboard.height = static_cast(par.parseInt()); + else if (kk == "mainLayout") fix.keyboard.mainLayout = par.parseString(); + else if (kk == "locale") fix.keyboard.locale = par.parseString(); + else par.skipValue(); + }); + } else if (key == "pointers") { + par.parseArray([&]() { + PointerSample ps; + par.parseObject([&](const std::string &pk) { + if (pk == "id") ps.id = static_cast(par.parseInt()); + else if (pk == "x") ps.x = static_cast(par.parseInt()); + else if (pk == "y") ps.y = static_cast(par.parseInt()); + else if (pk == "t") ps.t = static_cast(par.parseInt()); + else par.skipValue(); + }); + fix.pointers.push_back(ps); + }); + } else { + par.skipValue(); + } + }); + + return fix; +} + +// Load and parse a fixture from a JSON file. +// Throws std::runtime_error if the file cannot be opened or is malformed. +inline TraceFixture loadFixture(const std::string &path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("Cannot open fixture file: " + path); + std::ostringstream ss; + ss << f.rdbuf(); + return parseFixture(ss.str()); +} + +} // namespace replay +} // namespace latinime diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml index b88909504..64aa3961a 100644 --- a/app/src/main/res/drawable/ic_edit.xml +++ b/app/src/main/res/drawable/ic_edit.xml @@ -8,6 +8,6 @@ android:viewportWidth="24" android:viewportHeight="24"> \ No newline at end of file diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml new file mode 100644 index 000000000..86b475c83 --- /dev/null +++ b/app/src/main/res/layout/handwriting_view.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_keyboard_frame.xml b/app/src/main/res/layout/main_keyboard_frame.xml index f4ef48811..5ce124c2e 100644 --- a/app/src/main/res/layout/main_keyboard_frame.xml +++ b/app/src/main/res/layout/main_keyboard_frame.xml @@ -35,6 +35,9 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e563c1c..97a09e119 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,11 @@ Capitalize the first word of each sentence Personal dictionary + Blocked words + Manage words blocked from suggestions + Block a word + Word is already blocked + Are you sure you want to unblock all words? Main dictionary @@ -204,6 +209,12 @@ Suggest recent screenshots Show recently taken screenshots as a suggestion + + Auto-read OTP from SMS + + Show the code from an incoming SMS as a suggestion you can tap to insert + + Dismiss OTP suggestion Compress screenshot suggestions @@ -442,6 +453,18 @@ Download complete! Restarting… Download failed: %s + + Load handwriting plugin + Provide an APK plugin to enable handwriting input + Provide a handwriting plugin APK.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust. + Load plugin APK + Delete plugin + Handwriting plugin imported successfully + Failed to load handwriting plugin APK + Handwriting + Handwriting plugin required + Please load the handwriting plugin library to enable drawing recognition. + Load Plugin Autospace after punctuation @@ -611,6 +634,15 @@ API Endpoint API endpoint URL https://api.groq.com/openai/v1/chat/completions + Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. + Allow insecure connections + Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. + Layouts + Theme & Custom Backgrounds + Dictionaries & Typing History + Clipboard History + General Settings + Select items to include: https://generativelanguage.googleapis.com/v1beta/models?key=%1$s https://api.groq.com/openai/v1/models @@ -1179,6 +1211,7 @@ New dictionary: Gesture Typing Library Main Dictionaries Emoji Libraries + Handwriting Input Plugin Dictionary Loading Guide To add a new dictionary:\n1. Obtain a .dict file for your language.\n2. Tap \"Manage Dictionaries\".\n3. Use the plus (+) button to import the file. Active @@ -1369,6 +1402,16 @@ New dictionary: Manage local LLM Offline AI Max Tokens Maximum length of the generated correction + Temperature + Controls randomness: lower is more focused and deterministic + Top-P (Nucleus Sampling) + Controls diversity of vocabulary: lower cuts off less likely options + Top-K + Limits choices to K most probable tokens + Min-P + Minimum probability threshold relative to most likely token + Show Thinking + Display reasoning process and internal thinking tokens if the model generates them diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..4d5bef7d4 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index e7d30bf4e..daed7186c 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -34,6 +34,19 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + val service = ProofreadService(context) + val modelPath = service.getModelPath() + if (modelPath.isNullOrBlank()) return + scope.launch { + ProofreadService.ModelHolder.loadModel(context, modelPath) + } + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ @@ -206,7 +219,7 @@ object ProofreadHelper { text = text, noTextErrorResId = R.string.proofread_no_text, errorResId = R.string.proofread_error, - apiCall = { service -> service.proofread(text, overridePrompt = prompt) }, + apiCall = { service -> service.proofread(text, overridePrompt = prompt, showThinking = showThinking) }, onSuccess = onSuccess, onError = onError ) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 57d1126e4..33318f2fd 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -9,9 +9,6 @@ import android.content.SharedPreferences import android.net.Uri import android.provider.OpenableColumns import android.util.Log -import ai.onnxruntime.OnnxTensor -import ai.onnxruntime.OrtEnvironment -import ai.onnxruntime.OrtSession import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import kotlinx.coroutines.Dispatchers @@ -20,62 +17,66 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.nehuatl.llamacpp.LlamaHelper import java.io.File -import java.io.FileOutputStream -import java.nio.FloatBuffer -import java.nio.LongBuffer /** - * Offline proofreading service using ONNX Runtime with T5 grammar correction models. - * - * T5 uses encoder-decoder architecture: - * 1. Encoder: Processes input text → encoder hidden states - * 2. Decoder: Uses hidden states to generate corrected text token by token - * + * Offline proofreading service using llamacpp-kotlin with GGUF models. + * + * Uses LlamaHelper for on-device inference with llama.cpp backend. + * Supports any GGUF model for text correction/generation. + * * Expected model files: - * - encoder_model_quant.onnx - * - init_decoder_quant.onnx (initial decoder) - * - tokenizer.json (T5 vocabulary) + * - Any GGUF format model file */ class ProofreadService(private val context: Context) { - val prefs: SharedPreferences by lazy { + val sharedPrefs: SharedPreferences by lazy { context.prefs() } + val prefs: SharedPreferences get() = sharedPrefs + // Singleton holder for model state to prevent reloading on every request object ModelHolder { - var ortEnvironment: OrtEnvironment? = null - var encoderSession: OrtSession? = null - var decoderSession: OrtSession? = null - var currentEncoderPath: String? = null - var currentDecoderPath: String? = null - var tokenizer: T5Tokenizer? = null + var llamaHelper: LlamaHelper? = null + var currentModelPath: String? = null var isModelAvailable: Boolean = true - private var modelDir: File? = null + var isModelLoaded: Boolean = false // Smart Unload Logic private var unloadJob: Job? = null private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes + private val loadMutex = Mutex() + + // Flow for LLM events + val llmFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) @Synchronized - fun scheduleUnload(context: Context) { // Context required to check prefs + fun scheduleUnload(context: Context) { unloadJob?.cancel() - // Check preference val prefs = context.prefs() val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED) if (keepLoaded) { - Log.i("OnnxProofreadService", "Model unload skipped (Keep Model Loaded enabled)") + Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)") return } unloadJob = scope.launch { delay(UNLOAD_DELAY_MS) unloadModel() - Log.i("OnnxProofreadService", "Offline AI model unloaded due to inactivity") + Log.i(TAG, "Offline AI model unloaded due to inactivity") } } @@ -88,103 +89,115 @@ class ProofreadService(private val context: Context) { @Synchronized fun unloadModel() { try { - encoderSession?.close() - decoderSession?.close() - ortEnvironment?.close() + llamaHelper?.release() } catch (e: Exception) { - Log.w("OnnxProofreadService", "Error closing ONNX sessions", e) + Log.w(TAG, "Error unloading llama model", e) } - encoderSession = null - decoderSession = null - ortEnvironment = null - currentEncoderPath = null - currentDecoderPath = null - tokenizer = null - isModelAvailable = true // Reset availability flag on unload + llamaHelper = null + currentModelPath = null + isModelLoaded = false + isModelAvailable = true } - @Synchronized - fun loadModel( + suspend fun loadModel( context: Context, - encoderPath: String, - decoderPath: String?, - tokenizerPath: String? - ): Boolean { - cancelUnload() // Cancel any pending unload since we are loading/using it - - // Check if already loaded with same paths - if (encoderSession != null && currentEncoderPath == encoderPath && - (decoderPath.isNullOrBlank() || (decoderSession != null && currentDecoderPath == decoderPath))) { + modelPath: String + ): Boolean = loadMutex.withLock { + cancelUnload() + + // Check if already loaded with same path + if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { return true } - unloadModel() // Ensure clean slate if paths changed + unloadModel() // Ensure clean slate if path changed return try { - // Create model cache directory - modelDir = File(context.cacheDir, "onnx_model") - modelDir!!.mkdirs() - - // Initialize tokenizer - tokenizer = T5Tokenizer() - if (!tokenizerPath.isNullOrBlank()) { - val tokenizerFile = copyUriToCache(context, Uri.parse(tokenizerPath), "tokenizer.json", modelDir!!) - if (tokenizerFile != null) { - tokenizer!!.loadVocab(tokenizerFile) + val contentResolver = context.contentResolver + val helper = LlamaHelper( + contentResolver, + scope, + llmFlow + ) + + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Detach model file descriptor + val uri = android.net.Uri.parse(modelPath) + val pfd = contentResolver.openFileDescriptor(uri, "r") + ?: throw IllegalArgumentException("Failed to open model file descriptor") + val modelFd = pfd.detachFd() + + // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs) + val cores = Runtime.getRuntime().availableProcessors() + val threads = if (cores <= 4) cores else 4 + + Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") + + // Construct parameters map + val params = mutableMapOf( + "model" to modelPath, + "model_fd" to modelFd, + "use_mmap" to false, + "use_mlock" to false, + "n_ctx" to 2048, + "embedding" to false, + "n_batch" to 512, + "n_threads" to threads, + "n_gpu_layers" to 0, + "vocab_only" to false, + "lora" to "", + "lora_scaled" to 1.0, + "rope_freq_base" to 0.0, + "rope_freq_scale" to 0.0 + ) + + // JNI callback called by native code for each token + val callback: (String) -> Unit = { word -> + try { + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + val currentAllText = allTextField.get(helper) as String + allTextField.set(helper, currentAllText + word) + + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + val currentCount = tokenCountField.get(helper) as Int + tokenCountField.set(helper, currentCount + 1) + + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) + } catch (e: Throwable) { + Log.e(TAG, "Error in native token callback", e) } } - // Initialize ONNX Runtime - ortEnvironment = OrtEnvironment.getEnvironment() - val sessionOptions = OrtSession.SessionOptions().apply { - setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) - setIntraOpNumThreads(4) - } + // Start the engine + val result = llama.startEngine(params, callback) - // Copy and load encoder - val encoderFile = copyUriToCache(context, Uri.parse(encoderPath), "encoder.onnx", modelDir!!) - if (encoderFile == null) { - Log.e("OnnxProofreadService", "Failed to copy encoder") - return false - } + val contextId = result?.get("contextId") as? Int + ?: throw IllegalStateException("contextId not found in result map") - encoderSession = ortEnvironment!!.createSession(encoderFile.absolutePath, sessionOptions) - currentEncoderPath = encoderPath + // Set currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + currentContextField.set(helper, contextId) - // Copy and load decoder if provided - if (!decoderPath.isNullOrBlank()) { - val decoderFile = copyUriToCache(context, Uri.parse(decoderPath), "decoder.onnx", modelDir!!) - if (decoderFile != null) { - decoderSession = ortEnvironment!!.createSession(decoderFile.absolutePath, sessionOptions) - currentDecoderPath = decoderPath - } - } - + // Emit Loaded event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath)) + + llamaHelper = helper + currentModelPath = modelPath + isModelLoaded = true isModelAvailable = true true } catch (e: Throwable) { - Log.e("OnnxProofreadService", "Failed to load ONNX models", e) + Log.e(TAG, "Failed to load GGUF model", e) isModelAvailable = false false } } - private fun copyUriToCache(context: Context, uri: Uri, targetName: String, dir: File): File? { - val targetFile = File(dir, targetName) - if (targetFile.exists() && targetFile.length() > 0) return targetFile - - return try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(targetFile).use { output -> - input.copyTo(output) - } - } - if (targetFile.exists() && targetFile.length() > 0) targetFile else null - } catch (e: Exception) { - Log.e("OnnxProofreadService", "Failed to copy $targetName", e) - null - } - } + private const val TAG = "LlamaProofreadService" } // AI Provider support (API compatibility) @@ -216,26 +229,26 @@ class ProofreadService(private val context: Context) { fun getGroqModel(): String = "Offline Mode" fun setGroqModel(model: String) { /* No-op */ } - // Model management - encoder path - fun getModelPath(): String? = prefs.getString(KEY_ENCODER_PATH, null) + // Model management - single model path (no encoder/decoder split) + fun getModelPath(): String? = sharedPrefs.getString(KEY_MODEL_PATH, null) fun setModelPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { - remove(KEY_ENCODER_PATH) + remove(KEY_MODEL_PATH) } else { - putString(KEY_ENCODER_PATH, path) + putString(KEY_MODEL_PATH, path) } apply() } ModelHolder.unloadModel() } - // Decoder path (separate setting) - fun getDecoderPath(): String? = prefs.getString(KEY_DECODER_PATH, null) + // Decoder path (kept for API compatibility, not used with llamacpp) + fun getDecoderPath(): String? = sharedPrefs.getString(KEY_DECODER_PATH, null) fun setDecoderPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_DECODER_PATH) } else { @@ -243,14 +256,13 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() } - // Tokenizer path (vocabulary file) - fun getTokenizerPath(): String? = prefs.getString(KEY_TOKENIZER_PATH, null) + // Tokenizer path (not needed with GGUF - tokenizer is embedded) + fun getTokenizerPath(): String? = sharedPrefs.getString(KEY_TOKENIZER_PATH, null) fun setTokenizerPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_TOKENIZER_PATH) } else { @@ -258,14 +270,18 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() - ModelHolder.tokenizer = null } - fun getSystemPrompt(): String = prefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" + fun getSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" fun setSystemPrompt(prompt: String) { - prefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() + } + + fun getTranslateSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, "") ?: "" + + fun setTranslateSystemPrompt(prompt: String) { + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, prompt).apply() } fun getModelName(): String { @@ -310,34 +326,31 @@ class ProofreadService(private val context: Context) { } /** - * Copy a content URI to cache and return the local file path. - */ - - - /** - * Run T5 encoder-decoder inference for grammar correction. - */ - /** - * Run T5 encoder-decoder inference for translation. + * Run llamacpp inference for translation. */ suspend fun translate(text: String): Result { - val target = prefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE - // T5 standard prefix for translation - val prompt = "translate English to $target: " - return proofread(text, overridePrompt = prompt) + val target = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE + val systemPromptTemplate = getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: Defaults.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT + val prompt = systemPromptTemplate.replace("{lang}", target) + return proofread(text, overridePrompt = prompt, targetLanguage = target) } /** - * Run T5 encoder-decoder inference. + * Run llamacpp inference for proofreading/text correction. */ - suspend fun proofread(text: String, overridePrompt: String? = null): Result = withContext(Dispatchers.IO) { - val encoderPath = getModelPath() - if (encoderPath.isNullOrBlank()) { - return@withContext Result.failure(ProofreadException("Model not loaded. Please select encoder ONNX file.")) + suspend fun proofread( + text: String, + overridePrompt: String? = null, + showThinking: Boolean? = null, + targetLanguage: String? = null + ): Result = withContext(Dispatchers.IO) { + val modelPath = getModelPath() + if (modelPath.isNullOrBlank()) { + return@withContext Result.failure(ProofreadException("Model not loaded. Please select a GGUF model file.")) } // Load model (or get cached) - if (!ModelHolder.loadModel(context, encoderPath, getDecoderPath(), getTokenizerPath())) { + if (!ModelHolder.loadModel(context, modelPath)) { Log.e(TAG, "Model load failed") return@withContext Result.failure(ProofreadException("Failed to load model.")) } @@ -346,343 +359,296 @@ class ProofreadService(private val context: Context) { ModelHolder.cancelUnload() try { - val maxTokens = prefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) - - // 1. Tokenize input - val prompt = overridePrompt ?: getSystemPrompt() - val inputText = if (prompt.isNotBlank()) "$prompt$text" else text - val inputIds = ModelHolder.tokenizer!!.encode(inputText, addPrefix = false) + val maxTokens = sharedPrefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) + val temp = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TEMP, Defaults.PREF_OFFLINE_TEMP) + val topP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TOP_P, Defaults.PREF_OFFLINE_TOP_P) + val topK = sharedPrefs.getInt(Settings.PREF_OFFLINE_TOP_K, Defaults.PREF_OFFLINE_TOP_K) + val minP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_MIN_P, Defaults.PREF_OFFLINE_MIN_P) + val showThinkingVal = showThinking ?: sharedPrefs.getBoolean(Settings.PREF_OFFLINE_SHOW_THINKING, Defaults.PREF_OFFLINE_SHOW_THINKING) - val batchSize = 1L - val seqLen = inputIds.size.toLong() - val inputShape = longArrayOf(batchSize, seqLen) - - // 2. Create input tensors - val inputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputIds), inputShape) - val attentionMask = LongArray(inputIds.size) { 1L } - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(attentionMask), inputShape) + // Build the prompt + val systemPrompt = overridePrompt ?: getSystemPrompt() + val fullPrompt = if (systemPrompt.contains("{text}")) { + systemPrompt.replace("{text}", text) + } else if (overridePrompt != null) { + // Translation or specific override + val examples = targetLanguage?.let { getTranslationFewShot(it) } ?: emptyList() + if (examples.isNotEmpty()) { + var builder = "Instruction: ${systemPrompt.trim()}\n\n" + for (ex in examples) { + builder += "Input: ${ex.first}\nOutput: ${ex.second}\n\n" + } + builder += "Input: $text\nOutput:" + builder + } else { + "Instruction: ${systemPrompt.trim()}\n\nInput: $text\nOutput:" + } + } else { + // Default proofreading with few-shot examples for better local model guidance + val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Output only the corrected text, nothing else." } + "Instruction: ${instruction.trim()}\n\n" + + "Input: heko hw r u\n" + + "Output: Hello, how are you?\n\n" + + "Input: what you name\n" + + "Output: What is your name?\n\n" + + "Input: $text\n" + + "Output:" + } - // 3. Run encoder - val encoderInputs = mapOf( - "input_ids" to inputTensor, - "attention_mask" to attentionTensor + // Collect generated text from the flow + val generatedText = StringBuilder() + val helper = ModelHolder.llamaHelper + ?: return@withContext Result.failure(ProofreadException("Model not available")) + + // Use predict with custom parameters + predictWithParams( + helper = helper, + prompt = fullPrompt, + temp = temp, + topP = topP, + topK = topK, + minP = minP, + maxTokens = maxTokens, + showThinking = showThinkingVal ) + // Collect events until done + ModelHolder.llmFlow.takeWhile { event -> + when (event) { + is LlamaHelper.LLMEvent.Ongoing -> { + generatedText.append(event.word) + true + } + is LlamaHelper.LLMEvent.Done -> { + false + } + is LlamaHelper.LLMEvent.Error -> { + throw ProofreadException(event.toString()) + } + else -> true + } + }.collect {} + + // Schedule unload after work is done + ModelHolder.scheduleUnload(context) + + val output = generatedText.toString().trim() + + // Robust cleaning of the generated output + var cleanedOutput = output + if (cleanedOutput.startsWith(fullPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(fullPrompt.length).trim() + } else if (systemPrompt.isNotBlank() && cleanedOutput.startsWith(systemPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(systemPrompt.length).trim() + if (cleanedOutput.startsWith(text, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(text.length).trim() + } + } - val startTime = System.currentTimeMillis() - val encoderResults = ModelHolder.encoderSession!!.run(encoderInputs) - val encoderTime = System.currentTimeMillis() - startTime + // Truncate at the first occurrence of subsequent template markers + val markers = listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:", "Input:", "Instruction:", "Output:", "Corrected:") + for (marker in markers) { + val idx = cleanedOutput.indexOf(marker, ignoreCase = true) + if (idx != -1) { + if (marker.startsWith("\n") || idx > 0) { + cleanedOutput = cleanedOutput.substring(0, idx).trim() + } + } + } - // Get encoder hidden states - val encoderOutput = encoderResults[0] - val hiddenStates = encoderOutput.value // [batch, seq, hidden_dim] + // Also truncate at any newline followed by a potential template header (e.g., "\nDraft email:", "\nCorrection:") + val headerRegex = Regex("\\n[a-zA-Z0-9 ]+:") + val match = headerRegex.find(cleanedOutput) + if (match != null) { + cleanedOutput = cleanedOutput.substring(0, match.range.first).trim() + } - // 4. Run decoder (if available) - val outputText = if (ModelHolder.decoderSession != null && hiddenStates is Array<*>) { - runDecoderLoop(hiddenStates, attentionMask, maxTokens) - } else { - Log.w(TAG, "Decoder not available, returning original text") - text + // Also strip common prefixes that the model might generate or echo + val prefixesToStrip = listOf( + "Output:", "Corrected:", "Translation:", "Response:", "Result:", + "Output: ", "Corrected: ", "Translation: ", "Response: ", "Result: " + ) + for (prefix in prefixesToStrip) { + if (cleanedOutput.startsWith(prefix, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(prefix.length).trim() + break + } } - // Clean up - inputTensor.close() - attentionTensor.close() - encoderResults.close() + // If the model wrapped the output in quotes, strip them + if (cleanedOutput.startsWith("\"") && cleanedOutput.endsWith("\"")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() + } + if (cleanedOutput.startsWith("'") && cleanedOutput.endsWith("'")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() + } - // Schedule unload after work is done - ModelHolder.scheduleUnload(context) - - // Strip prompt prefix if model echoed it back - val cleanedOutput = if (prompt.isNotBlank() && outputText.startsWith(prompt, ignoreCase = true)) { - outputText.removePrefix(prompt).trimStart() + // Post-process to strip thinking/reasoning tags if showThinkingVal is false + val finalOutput = if (!showThinkingVal) { + stripThinkingTags(cleanedOutput) } else { - outputText + cleanedOutput } - - if (cleanedOutput.isNotBlank()) { - Result.success(cleanedOutput) + + Log.i(TAG, "proofread: input='$text' prompt='$fullPrompt' generated='$output' final='$finalOutput'") + if (finalOutput.isNotBlank()) { + Result.success(finalOutput) } else { Result.success(text) } } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) { + // Cancel completion job if running + try { + val helper = ModelHolder.llamaHelper + if (helper != null) { + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + val completionJob = completionJobField.get(helper) as? Job + completionJob?.cancel() + } + } catch (ex: Throwable) { + Log.w(TAG, "Failed to cancel completion job", ex) + } + throw e + } Log.e(TAG, "Proofread failed", e) ModelHolder.scheduleUnload(context) // Ensure we still schedule unload on error Result.failure(ProofreadException(e.message ?: "Unknown error")) } } - /** - * Run decoder auto-regressively to generate output tokens. - * Supports multiple T5 decoder variants: - * - Basic decoder (input_ids, encoder_hidden_states, encoder_attention_mask) - * - Decoder with past (adds past_key_values/pkv_* inputs) - * - Merged decoder (adds use_cache_branch flag) - */ - private fun runDecoderLoop(encoderHiddenStates: Array<*>, encoderAttentionMask: LongArray, maxTokens: Int): String { - if (ModelHolder.decoderSession == null) return "" - + private fun predictWithParams( + helper: LlamaHelper, + prompt: String, + temp: Float, + topP: Float, + topK: Int, + minP: Float, + maxTokens: Int, + showThinking: Boolean + ) { try { - // Get hidden states as 3D array [batch, seq, hidden] - @Suppress("UNCHECKED_CAST") - val hiddenArray = encoderHiddenStates[0] as? Array ?: return "" - val seqLen = hiddenArray.size - val hiddenDim = hiddenArray[0].size - - - - // Flatten hidden states for tensor - val flatHidden = FloatArray(seqLen * hiddenDim) - for (i in 0 until seqLen) { - System.arraycopy(hiddenArray[i], 0, flatHidden, i * hiddenDim, hiddenDim) - } - - // Create encoder_hidden_states tensor - val hiddenShape = longArrayOf(1, seqLen.toLong(), hiddenDim.toLong()) - val hiddenTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flatHidden), hiddenShape) - - // Create encoder attention mask tensor - val attentionShape = longArrayOf(1, encoderAttentionMask.size.toLong()) - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(encoderAttentionMask), attentionShape) - - // Analyze decoder inputs to determine model type - val inputNames = ModelHolder.decoderSession!!.inputNames.toList() - val pkvInputNames = inputNames.filter { it.startsWith("past_key_values") || it.startsWith("pkv") } - val useCacheBranchInput = inputNames.find { it == "use_cache_branch" } - val numLayers = pkvInputNames.size / 4 // 4 tensors per layer (decoder key/value, encoder key/value) - - val hasPkvInputs = pkvInputNames.isNotEmpty() - val isMergedDecoder = useCacheBranchInput != null - - - - // Start with decoder start token (pad_token = 0 for T5) - val generatedTokens = mutableListOf(0L) - val eosTokenId = ModelHolder.tokenizer!!.getEosTokenId() - - // KV-cache storage for decoders that output present.* tensors - var pastKeyValues: Map? = null - - val startTime = System.currentTimeMillis() - - for (step in 0 until maxTokens) { - // For KV-cache models, only pass the last token after first step - var isValidPkv = false - if (hasPkvInputs && pastKeyValues != null) { - val currentPkv = pastKeyValues!!.values.firstOrNull() - if (currentPkv != null) { - val sequenceLength = currentPkv.info.shape[2] - if (sequenceLength > 0) { - isValidPkv = true - } - } - } - - // CRITICAL FIX: Only use valid pastKeyValues if model actually accepts PKV inputs - val inputTokens = if (step > 0 && isValidPkv && hasPkvInputs) { - longArrayOf(generatedTokens.last()) - } else { - generatedTokens.toLongArray() - } - - val decoderShape = longArrayOf(1, inputTokens.size.toLong()) - val decoderInputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputTokens), decoderShape) - - // Build decoder inputs - val decoderInputs = mutableMapOf() - for (inputName in inputNames) { - when { - inputName.contains("input_ids") || inputName.contains("decoder_input_ids") -> - decoderInputs[inputName] = decoderInputTensor - inputName.contains("encoder_hidden_states") || inputName.contains("hidden_states") -> - decoderInputs[inputName] = hiddenTensor - inputName.contains("encoder_attention_mask") || inputName.contains("attention_mask") -> - decoderInputs[inputName] = attentionTensor - } - } - - // Handle use_cache_branch for merged decoders - if (isMergedDecoder && useCacheBranchInput != null) { - val useCacheValue = step > 0 // false on first run, true after - val useCacheTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, booleanArrayOf(useCacheValue)) - decoderInputs[useCacheBranchInput] = useCacheTensor - } - - // Add past_key_values from previous step (if available and model expects them) - if (isValidPkv) { - for ((name, tensor) in pastKeyValues!!) { - // Map present.X.* output names to past_key_values.X.* or pkv_* input names - val inputName = name.replace("present", "past_key_values") - if (inputNames.contains(inputName)) { - decoderInputs[inputName] = tensor - } else { - // Try pkv format (pkv_0, pkv_1, etc.) - val pkvMatch = pkvInputNames.find { it.endsWith(name.substringAfter("present.")) } - if (pkvMatch != null) { - decoderInputs[pkvMatch] = tensor - } - } - } - } else if (hasPkvInputs) { - // First step with PKV model or invalid cache: provide zero tensors - // T5 pkv format: pkv_0 to pkv_N where first half is decoder self-attn, second half is encoder cross-attn - // Shape: [batch, num_heads, seq_len, head_dim] - - var numHeads = 8L // Default T5-small - - // improved head detection from model metadata - try { - // Try to find the shape of the first PKV input - val pkvInfo = ModelHolder.decoderSession!!.inputInfo[pkvInputNames.first()] - val shape = pkvInfo?.info as? ai.onnxruntime.TensorInfo - if (shape != null) { - val dims = shape.shape - // Shape is usually [batch, heads, seq, dim] -> index 1 - if (dims.size == 4 && dims[1] > 0) { - numHeads = dims[1] - } - } - } catch (e: Exception) { - Log.w(TAG, "Could not detect numHeads from model info", e) - } - - val headDim = hiddenDim.toLong() / numHeads - val numPkv = pkvInputNames.size - - for (pkvName in pkvInputNames) { - // Determine if this is encoder cross-attention or decoder self-attention - // pkv_0 to pkv_(N/2-1) = decoder self-attention (seq_len = 0 initially) - // pkv_(N/2) to pkv_(N-1) = encoder cross-attention (seq_len = encoder_seq_len) - val pkvIndex = pkvName.removePrefix("pkv_").removePrefix("past_key_values.").toIntOrNull() ?: 0 - val isEncoderPkv = pkvIndex >= numPkv / 2 || pkvName.contains("encoder") - - val pkvSeqLen = if (isEncoderPkv) seqLen.toLong() else 0L - val pkvShape = longArrayOf(1, numHeads, pkvSeqLen, headDim) - val emptyPkv = FloatArray((1 * numHeads * pkvSeqLen * headDim).toInt()) - val pkvTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(emptyPkv), pkvShape) - decoderInputs[pkvName] = pkvTensor - } - } - + // Get currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + val currentContext = currentContextField.get(helper) as? Int ?: throw IllegalStateException("Model not loaded yet") + + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Reset tokenCount and allText + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + tokenCountField.set(helper, 0) + + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + allTextField.set(helper, "") + + // Emit Started event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Started(prompt)) + + // Build parameters map + val params = mutableMapOf( + "prompt" to prompt, + "emit_partial_completion" to true, + "temperature" to temp.toDouble(), + "top_p" to topP.toDouble(), + "top_k" to topK, + "min_p" to minP.toDouble(), + "n_predict" to maxTokens, + "stop" to listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:") + ) - - // Run decoder step - val decoderResults = ModelHolder.decoderSession!!.run(decoderInputs) - - // Get logits (usually first output) - var logitsOutput: Any? = null - val newPastKeyValues = mutableMapOf() - - for (i in 0 until decoderResults.size()) { - val outputInfo = ModelHolder.decoderSession!!.outputNames.toList()[i] - val outputValue = decoderResults[i] - - when { - outputInfo == "logits" || i == 0 -> { - logitsOutput = outputValue.value - } - outputInfo.startsWith("present") -> { - // Save present.* outputs for next step - // Need to copy tensor data since result will be closed - val tensorValue = outputValue.value - if (tensorValue is Array<*>) { - @Suppress("UNCHECKED_CAST") - val floatData = tensorValue as? Array>> - if (floatData != null) { - val batch = floatData.size - val heads = floatData[0].size - val seqL = floatData[0][0].size - val dim = floatData[0][0][0].size - val flat = FloatArray(batch * heads * seqL * dim) - var idx = 0 - for (b in 0 until batch) { - for (h in 0 until heads) { - for (s in 0 until seqL) { - System.arraycopy(floatData[b][h][s], 0, flat, idx, dim) - idx += dim - } - } - } - val shape = longArrayOf(batch.toLong(), heads.toLong(), seqL.toLong(), dim.toLong()) - newPastKeyValues[outputInfo] = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flat), shape) - } - } - } - } - } - - val nextToken = getNextToken(logitsOutput, inputTokens.size.toLong() - 1) - - // Close previous PKV tensors and update with new ones - pastKeyValues?.values?.forEach { it.close() } - pastKeyValues = if (newPastKeyValues.isNotEmpty()) newPastKeyValues else null - - decoderInputTensor.close() - decoderResults.close() - - // Check for EOS - if (nextToken == eosTokenId) { - break + // Get completionJob field + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + + // Launch completion using helper.scope + val job = helper.scope.launch { + val startTime = System.currentTimeMillis() + try { + llama.launchCompletion(currentContext, params) + } catch (e: Throwable) { + Log.e(TAG, "Completion failed", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Completion failed: ${e.message}")) + return@launch } - - generatedTokens.add(nextToken) + val duration = System.currentTimeMillis() - startTime + val allText = allTextField.get(helper) as String + val tokenCount = tokenCountField.get(helper) as Int + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Done(allText, tokenCount, duration)) } - - val decoderTime = System.currentTimeMillis() - startTime - - // Clean up - pastKeyValues?.values?.forEach { it.close() } - hiddenTensor.close() - attentionTensor.close() - - // Decode tokens (skip first token which is start token) - val outputTokens = generatedTokens.drop(1).toLongArray() - return ModelHolder.tokenizer!!.decode(outputTokens) - - } catch (e: Exception) { - Log.e(TAG, "Decoder loop failed", e) - return "" + completionJobField.set(helper, job) + } catch (e: Throwable) { + Log.e(TAG, "Failed to setup prediction", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Failed to setup prediction: ${e.message}")) } } - /** - * Get next token from logits using greedy decoding. - */ - private fun getNextToken(logits: Any?, position: Long): Long { - val pos = position.toInt() - return when (logits) { - is Array<*> -> { - // Shape: [batch, seq, vocab] - @Suppress("UNCHECKED_CAST") - val batchLogits = logits[0] as? Array - if (batchLogits != null && pos < batchLogits.size) { - val vocabLogits = batchLogits[pos] - // Argmax - vocabLogits.indices.maxByOrNull { vocabLogits[it] }?.toLong() ?: 0L - } else 0L - } - is FloatArray -> { - // Direct vocab logits - logits.indices.maxByOrNull { logits[it] }?.toLong() ?: 0L - } - else -> { - Log.w(TAG, "Unknown logits type: ${logits?.javaClass}") - 0L - } - } + private fun stripThinkingTags(text: String): String { + return text + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("

[\\s\\S]*?
", RegexOption.IGNORE_CASE), "") + .trim() } - + private fun getTranslationFewShot(targetLanguage: String): List> { + val lang = targetLanguage.trim().lowercase() + return when { + lang.contains("french") || lang.contains("français") -> listOf( + "Hello, how are you?" to "Bonjour, comment allez-vous?", + "My name is Alex." to "Je m'appelle Alex." + ) + lang.contains("spanish") || lang.contains("español") -> listOf( + "Hello, how are you?" to "Hola, ¿cómo estás?", + "My name is Alex." to "Mi nombre es Alex." + ) + lang.contains("german") || lang.contains("deutsch") -> listOf( + "Hello, how are you?" to "Hallo, wie geht es dir?", + "My name is Alex." to "Mein Name ist Alex." + ) + lang.contains("italian") || lang.contains("italiano") -> listOf( + "Hello, how are you?" to "Ciao, come stai?", + "My name is Alex." to "Il mio nome è Alex." + ) + lang.contains("portuguese") || lang.contains("português") -> listOf( + "Hello, how are you?" to "Olá, como você está?", + "My name is Alex." to "Meu nome é Alex." + ) + lang.contains("dutch") || lang.contains("nederlands") -> listOf( + "Hello, how are you?" to "Hallo, hoe gaat het met je?", + "My name is Alex." to "Mijn naam is Alex." + ) + lang.contains("russian") || lang.contains("русский") -> listOf( + "Hello, how are you?" to "Привет, как дела?", + "My name is Alex." to "Меня зовут Алекс." + ) + lang.contains("chinese") || lang.contains("中文") || lang.contains("汉语") -> listOf( + "Hello, how are you?" to "你好,你好吗?", + "My name is Alex." to "我的名字是亚历克斯。" + ) + lang.contains("japanese") || lang.contains("日本語") -> listOf( + "Hello, how are you?" to "こんにちは、お元気ですか?", + "My name is Alex." to "私の名前はアレックスです。" + ) + lang.contains("hindi") || lang.contains("हिन्दी") -> listOf( + "Hello, how are you?" to "नमस्ते, आप कैसे हैं?", + "My name is Alex." to "मेरा नाम एलेक्स है।" + ) + else -> emptyList() + } + } class ProofreadException(message: String) : Exception(message) class TranslateException(message: String) : Exception(message) companion object { - private const val TAG = "OnnxProofreadService" - private const val KEY_ENCODER_PATH = "offline_model_path" + private const val TAG = "LlamaProofreadService" + private const val KEY_MODEL_PATH = "offline_model_path" private const val KEY_DECODER_PATH = "offline_decoder_path" private const val KEY_TOKENIZER_PATH = "offline_tokenizer_path" - val AVAILABLE_MODELS = listOf("T5 Grammar Correction (ONNX)") + val AVAILABLE_MODELS = listOf("GGUF Model (Local)") } } diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ec537f622..3e7537c8c 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -23,6 +23,11 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + @JvmStatic + fun preloadModel(context: Context) { + // No-op for offlinelite flavor (no AI support) + } + @JvmStatic fun cancelCurrentOperation() { /* No-op */ } diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt index 30cfa743f..49cb8584c 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -20,7 +20,7 @@ class ProofreadService(private val context: Context) { val prefs: SharedPreferences get() = context.prefs() // Always returns GEMINI as default, but methods do nothing - fun getProvider(): AIProvider = AIProvider.GEMINI +fun getProvider(): AIProvider = AIProvider.GEMINI fun setProvider(provider: AIProvider) { /* No-op */ } suspend fun fetchAvailableModels(provider: AIProvider): List = emptyList() @@ -34,6 +34,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ee26866f6..08e7b78b5 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -36,6 +36,14 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + // No-op for standard flavor (runs API based proofreader) + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index acb0b29a1..5230a545f 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -62,14 +62,14 @@ class ProofreadService(private val context: Context) { val prefs: SharedPreferences get() = context.prefs() // Provider selection - fun getProvider(): AIProvider { - val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) - return try { - AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) - } catch (e: IllegalArgumentException) { - AIProvider.GEMINI - } +fun getProvider(): AIProvider { + val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) + return try { + AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) + } catch (e: IllegalArgumentException) { + AIProvider.GEMINI } +} fun setProvider(provider: AIProvider) { context.prefs().edit().putString(KEY_PROVIDER, provider.name).apply() @@ -171,6 +171,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "Fix grammar and spelling" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null @@ -238,6 +240,13 @@ class ProofreadService(private val context: Context) { securePrefs.edit().putString(KEY_HF_ENDPOINT, endpoint.trim()).apply() } + fun isAllowInsecureConnections(): Boolean = + context.prefs().getBoolean( + helium314.keyboard.latin.settings.Settings.PREF_AI_ALLOW_INSECURE_CONNECTIONS, + helium314.keyboard.latin.settings.Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS + ) + + /** * Tests the API key by making a simple request. * @return Result with success message or error @@ -463,9 +472,22 @@ class ProofreadService(private val context: Context) { ) } - val url = URL(getHuggingFaceEndpoint()) + val endpoint = getHuggingFaceEndpoint() + val isHttp = endpoint.startsWith("http://", ignoreCase = true) + val allowInsecure = isAllowInsecureConnections() + + if (isHttp && !allowInsecure) { + return Result.failure( + ProofreadException(context.getString(R.string.insecure_connection_blocked)) + ) + } + + val url = URL(endpoint) val connection = url.openConnection() as HttpURLConnection - + if (allowInsecure && connection is javax.net.ssl.HttpsURLConnection) { + bypassSSLVerification(connection) + } + return try { connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json") @@ -511,6 +533,22 @@ class ProofreadService(private val context: Context) { } } + private fun bypassSSLVerification(connection: javax.net.ssl.HttpsURLConnection) { + try { + val trustAllCerts = arrayOf(object : javax.net.ssl.X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sslContext = javax.net.ssl.SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + connection.sslSocketFactory = sslContext.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } catch (e: Exception) { + Log.e("ProofreadService", "Failed to bypass SSL verification", e) + } + } + private fun parseOpenAIResponse(response: String, showThinking: Boolean): Result { return try { // OpenAI-compatible format: {"choices": [{"message": {"content": "..."}}]} @@ -520,6 +558,39 @@ class ProofreadService(private val context: Context) { val firstChoice = choices.getJSONObject(0) val message = firstChoice.optJSONObject("message") var content = message?.optString("content", "") ?: "" + + if (message != null) { + val contentArray = message.optJSONArray("content") + if (contentArray != null) { + val parts = mutableListOf() + for (i in 0 until contentArray.length()) { + when (val part = contentArray.opt(i)) { + is String -> if (part.isNotBlank()) parts.add(part) + is JSONObject -> { + val type = part.optString("type", "") + val text = part.optString("text", "").ifBlank { + part.optString("content", "") + } + if (text.isNotBlank() && (showThinking || type != "reasoning")) { + parts.add(text) + } + } + } + } + content = parts.joinToString("\n") + } + + content = content.trim() + if (content.isBlank() && showThinking) { + content = message.optString("reasoning_content", "").trim().ifBlank { + message.optString("reasoning", "").trim() + } + } + } + + if (content.isBlank()) { + content = firstChoice.optString("text", "").trim() + } if (!showThinking && content.isNotBlank()) { // Filter out ... blocks diff --git a/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt new file mode 100644 index 000000000..f6fd34416 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt @@ -0,0 +1,47 @@ +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class HandwritingLoaderTest { + + private lateinit var context: Context + private lateinit var testApk: File + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + testApk = File(context.cacheDir, "test_plugin.apk") + testApk.writeText("dummy content for handwriting plugin") + } + + @After + fun tearDown() { + testApk.delete() + HandwritingLoader.removePlugin(context) + } + + @Test + fun testImportCleanupOnInvalidApk() { + // Initially no plugin + assertFalse(HandwritingLoader.hasPlugin(context)) + + // Import invalid apk + val uri = Uri.fromFile(testApk) + val result = HandwritingLoader.importPlugin(context, uri) + assertFalse(result) // Must fail because dummy text is not a valid DEX/APK with the class + + // Verify the file was cleaned up on failure + val apkFile = File(context.filesDir, "handwriting_plugin.apk") + assertFalse(apkFile.exists()) + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt new file mode 100644 index 000000000..e3296d37d --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic + +import helium314.keyboard.latin.SuggestedWords +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.dictionary.Dictionary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [InputLogic.computeSpacingSignals] (#14 spacing policy): the free per-keystroke + * `complete` + `prefixRichScore` signals derived from the suggestion results. Pure logic. + */ +class SpacingSignalsTest { + + // mDictType != "user_typed" -> counts as a "real" dictionary source for `complete`. + private val realDict: Dictionary = Dictionary.DICTIONARY_APPLICATION_DEFINED + private val userTyped: Dictionary = Dictionary.DICTIONARY_USER_TYPED + + private fun info(word: String, kind: Int, dict: Dictionary): SuggestedWordInfo = + SuggestedWordInfo(word, "", 0, kind, dict, + SuggestedWordInfo.NOT_AN_INDEX, SuggestedWordInfo.NOT_A_CONFIDENCE) + + private fun words(typed: SuggestedWordInfo?, typedValid: Boolean, + list: List): SuggestedWords = + SuggestedWords(ArrayList(list), null, typed, typedValid, false, false, + SuggestedWords.INPUT_STYLE_TYPING, SuggestedWords.NOT_A_SEQUENCE_NUMBER) + + @Test fun `empty suggestions yield no signals`() { + val s = InputLogic.computeSpacingSignals(SuggestedWords.getEmptyInstance()) + assertFalse(s.complete) + assertEquals(0f, s.prefixRichScore, 0f) + } + + @Test fun `valid typed word from a real dictionary is complete`() { + val typed = info("the", SuggestedWordInfo.KIND_TYPED, realDict) + assertTrue(InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).complete) + } + + @Test fun `valid typed word from the user-typed source is NOT complete`() { + val typed = info("xyzzy", SuggestedWordInfo.KIND_TYPED, userTyped) + assertFalse(InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).complete) + } + + @Test fun `invalid typed word is not complete`() { + val typed = info("teh", SuggestedWordInfo.KIND_TYPED, realDict) + assertFalse(InputLogic.computeSpacingSignals(words(typed, false, listOf(typed))).complete) + } + + @Test fun `prefix-rich score is the fraction of completions`() { + val typed = info("ba", SuggestedWordInfo.KIND_TYPED, realDict) + val list = listOf( + typed, + info("bad", SuggestedWordInfo.KIND_COMPLETION, realDict), + info("bat", SuggestedWordInfo.KIND_COMPLETION, realDict), + info("ball", SuggestedWordInfo.KIND_COMPLETION, realDict), + ) + // 3 completions out of 4 candidates. + assertEquals(0.75f, InputLogic.computeSpacingSignals(words(typed, false, list)).prefixRichScore, 1e-6f) + } + + @Test fun `no completions yields zero prefix-rich score`() { + val typed = info("the", SuggestedWordInfo.KIND_TYPED, realDict) + assertEquals(0f, + InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).prefixRichScore, 0f) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 796e20fdc..0db34dadc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -16,6 +16,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | 👐 **[Two-thumb Typing (experimental)](#two-thumb-typing-experimental)** | Mix taps and swipes naturally; manual spacing; Nintype-style. | | 📝 **[Text Expander](#6-text-expander)** | Custom text shortcut expansion. | | 🖱️ **[Touchpad Mode](#7-touchpad-mode)** | Full-screen touchpad gestures and controls. | +| ✍️ **[Handwriting Input](#8-handwriting-input)** | Use handwriting recognition to draw letters directly on a canvas. | ## Summary of New Features @@ -23,6 +24,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | :--- | :--- | :--- | | **Multi-Provider AI** | Uses Gemini, Groq, or OpenAI to proofread/rewrite text. Fetch latest models dynamically. | `AI Integration > Set AI Provider` | | **Offline Proofreading** | Private, on-device AI for grammar (requires downloads). | `AI Integration > Offline Proofreading` | +| **GGUF Model Support** | Load and run highly quantized, compact GGUF models on-device for offline proofreading/translation. | `Advanced > GGUF Model (.gguf)` | | **Custom AI Keys** | 10 toolbar keys with custom prompts, tags (themed capsules), and toggle settings (supports hashtags). | `AI Integration > Custom Keys` | | **AI Translation** | Translates selected text via your configured AI provider (includes separate model selector). | Toolbar > Translate Icon | | **Floating Keyboard** | Detach the keyboard into a draggable window with a persistent mode option. | Toolbar > Floating Keyboard | @@ -39,6 +41,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Clipboard Undo** | Undo swipe-to-delete on clipboard items with a timed undo bar. | *Automatic (on swipe delete)* | | **Two-thumb Typing** | Mix taps and swipes naturally, multi-tap then swipe, manual spacing, recognition tweaks. All experimental and opt-in. | `Two-thumb typing (experimental)` | | **Text Expander** | Expand custom shortcuts using dynamic template variables (date, time, clipboard, custom placeholders). | `Text correction > Text Expander` | +| **Handwriting Input** | Draw letters or words directly on the screen keyboard space to type (standard variant, requires plugin). | `Libraries > Handwriting Input Plugin` | --- @@ -90,7 +93,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Groq** | 🟡 Average | 🟢 Easy | High | **Speed** | | **Google Gemini** | 🔴 Low | 🟢 Easy | Generous | General Purpose | | **HF/OpenAI-compatible** | ⚙️ *Varies* | 🟡 Medium | *Varies* | **Fully Customizable** | -| **Offline (ONNX)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | +| **Offline (Llama)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | > [!TIP] > The **HF/OpenAI-compatible** option is fully customizable—you can change the API endpoint, token, and model to use *any* OpenAI-compatible service (OpenRouter, Mistral, DeepSeek, HuggingFace, etc.). @@ -316,30 +319,30 @@ Control how the result is inserted. **Note**: This feature is only available in the "Offline" build flavor of LeanType. -Offline proofreading runs entirely on your device using the ONNX Runtime engine. No data leaves your device. +Offline proofreading runs entirely on your device using the `llama.cpp` runtime. No data leaves your device. > [!NOTE] > **Status: Beta / Experimental** -> This feature is in a test phase. The engine is designed to be compatible with various T5-based ONNX models (Basic, Quantized, KV-Cache). We encourage you to experiment with different models to find the best balance of speed and accuracy for your device. +> Running large language models on device requires a modern smartphone with sufficient RAM (typically 6GB+). We recommend using highly quantized, compact GGUF models (e.g. Q4_K_M or IQ4_NL) for the best balance of speed, accuracy, and memory usage. The overall accuracy of proofreading and translations will depend entirely on the capabilities of the specific model you choose. ### Setup Instructions -1. **Download Model Files**: Download the **Encoder**, **Decoder**, and **Tokenizer** for your chosen model from the table below. +1. **Download a GGUF Model**: Download a compatible `.gguf` model file (see Recommended Models below). 2. **Configure App**: * Go to **Settings > Advanced**. - * **Encoder Model**: Select the downloaded `.onnx` encoder file. - * **Decoder Model**: Select the downloaded `.onnx` decoder file. - * **Tokenizer**: Select the `tokenizer.json` file. - * **System Instruction**: Enter the text specified in the "System Instruction" column for your model (leave empty if specified). + * **GGUF Model**: Select the downloaded `.gguf` model file. + * **System Instruction**: (Optional) Customize the prompt used to guide the model when proofreading text. + * **Translate Instruction**: (Optional) Customize the prompt used for translation. + * **Target Language**: Select the target language for offline translation. + * **Sampling Settings**: Adjust temperature, Top-K, and Top-P to control model creativity. ### Recommended Models -| Model & Purpose | Performance / Size | System Instruction | Download Links (Direct) | -| :--- | :--- | :--- | :--- | -| **Visheratin T5 Tiny**
*(Grammar Correction Only)* | ⚡ **Fastest**
~35 MB
Low RAM usage | **Empty**
(Leave blank) | • [Encoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/encoder_model_quant.onnx)
• [Decoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/init_decoder_quant.onnx)
• [Tokenizer](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/tree/main) | -| **Flan-T5 Small**
*(Translation & General)* | 🐢 **Slower**
~300 MB
Higher accuracy | **Required**
`fix grammar: `
or
`translate English to Spanish: ` | • [Encoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/encoder_model_quantized.onnx)
• [Decoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/decoder_model_quantized.onnx)
• [Tokenizer](https://huggingface.co/Xenova/flan-t5-small/tree/main) | +* **Llama 3.2 1B Instruct (Q4_K_M)**: Excellent general purpose compact model (~900 MB). +* **Qwen 2.5 1.5B Instruct (Q4_K_M)**: High accuracy and quality, fast on modern devices (~1.1 GB). +* **Qwen 2.5 0.5B Instruct (Q4_K_M)**: Extremely lightweight, very fast with minimal memory footprint (~350 MB). -*Note: For Flan-T5, the quantized models linked above are standard recommendations. Users have also reported success with `bnb4` quantized variants if available.* +You can find and download these models in GGUF format on HuggingFace (e.g., from users like `bartowski` or `Qwen`). --- @@ -377,8 +380,46 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Toolbar shortcut**: Tap the **Touchpad** icon in the toolbar for a persistent touchpad overlay. ### Touchpad Gestures -* **Single-finger drag**: Moves the cursor in 2D space (simulating arrow keys left/right/up/down) to navigate text. -* **Two-finger drag**: Performs fast vertical scrolling (simulating arrow keys up/down). -* **Two-finger tap**: Simulates a mouse click/Enter. -* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. Releasing the finger exits selection mode. -* **Double tap by single finger**: Deletes the selected text or words (if a text selection exists). + +#### 1 Finger (Navigation & Selection) +* **Drag**: Moves the cursor precisely character-by-character. +* **Double Tap**: Selects the word under the cursor. +* **Long Press & Drag**: Enters text selection mode and selects text as you drag. + +#### 2 Fingers (Navigation, Clipboard, History & Deletion) +* **Drag Left/Right**: Moves the cursor horizontally word-by-word. +* **Swipe Up**: Undo. +* **Swipe Down**: Redo. +* **Tap**: Inserts a space character. +* **Double Tap**: Copies selected text (or Pastes clipboard contents if no selection exists). +* **Triple Tap**: Cuts selected text (or Selects All if no selection exists). +* **Press & Hold (Long Press)**: Deletes (backspaces) selection / word to the left. Repeats automatically if held. + +--- + +## 8. Handwriting Input + +> [!NOTE] +> **Availability**: This feature is only available in the **Standard** (`-standard-release.apk`) and **Standard Optimised** build flavors. It is excluded from the **Offline** and **Offline Lite** variants. + +LeanType integrates a handwriting recognition canvas that allows you to write characters directly on the keyboard using your finger or a stylus. + +### Setup Instructions + +1. **Install the Plugin**: + * Go to **Settings > Libraries**. + * Under **Handwriting Input Plugin**, tap **Download** to pull the latest plugin APK from the [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin) GitHub repository. + * Alternatively, you can tap to load a locally downloaded plugin APK file. + * Review the security warning and confirm the installation. The app will verify and register the plugin. + +2. **Accessing the Handwriting Key**: + * The **Handwriting** icon (represented by a pencil/edit icon) is placed on your keyboard toolbar by default in supported variants. + * If it is not showing, you can customize the toolbar under **Settings > Preferences > Keyboard toolbar** to enable it. + +### How to Use + +1. Tap the **Handwriting** icon in the toolbar. +2. The keyboard area will switch to a handwriting drawing canvas. +3. Draw characters, words, or punctuation symbols on the canvas. The keyboard will automatically inputs recognized characters. +4. Tap the **Clear (X)** button on the bottom row to clear the current drawing canvas. +5. Tap the **Handwriting** icon again to toggle back to the standard keyboard layout. diff --git a/docs/SPACING_TEST_PLAN.md b/docs/SPACING_TEST_PLAN.md new file mode 100644 index 000000000..f7705ca21 --- /dev/null +++ b/docs/SPACING_TEST_PLAN.md @@ -0,0 +1,275 @@ +# Spacing Policy Playtest Plan + +Use this when tuning the two-thumb spacing policy. The goal is practical feel, not reading raw telemetry. + +The most important distinction: + +- **Auto-finish** = when the word commits. +- **Auto-space** = when/if a trailing space appears after that commit. + +Those are separate. Several settings sound similar but affect different stages. + +--- + +## 1. Timing model cheat sheet + +| Setting | Affects | Tap-only words | Swiped words | Tap-then-swipe words | +|---|---|---|---|---| +| **Grace timer** | base auto-finish delay | yes, unless swipe-only finish is ON | yes | yes | +| **Tap extra grace** | extra delay for tap-started combining | yes, unless swipe-only finish is ON | no | yes before the swipe extends | +| **Only auto-finish swiped words** | whether tap-only words can auto-commit | **blocks tap-only auto-finish** | no effect | still allows auto-finish after the swipe fragment | +| **Adapt pause to the word** | modifies the grace timer by word signals | only if the word is allowed to auto-finish | yes | yes after the swipe fragment | +| **Only auto-space after swipes** | trailing space only | tap word may still commit, just no space | allows space | allows space after swiped fragment | +| **Defer grace space** | when the space materializes | no change to commit timing | no change to commit timing | no change to commit timing | + +Recommended safety defaults: + +```text +Only auto-finish swiped words: ON +Only auto-space after swipes: ON if you dislike tap-created spaces +Adapt pause to the word: ON only while tuning / testing +Defer grace space: ON only while testing deferred-space feel +``` + +--- + +## 2. Presets to try + +Do not tune one slider at a time first. Try a complete profile. + +### Profile A — Conservative / safe + +```text +Base grace timer: 650 ms +Tap extra grace: 250 ms +Finished-word speed-up: 150 ms +Extendable-stem patience: 700 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 500 ms +- swiped prefix-rich stems: roughly 900–1200 ms +- tap-only words: no auto-finish + +### Profile B — Balanced + +```text +Base grace timer: 550 ms +Tap extra grace: 250 ms +Finished-word speed-up: 250 ms +Extendable-stem patience: 600 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 300 ms +- medium stems: about 700–900 ms +- prefix-heavy stems: about 1000 ms + +### Profile C — Fast / assisted + +```text +Base grace timer: 450 ms +Tap extra grace: 250 ms +Finished-word speed-up: 300 ms +Extendable-stem patience: 650 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 150–250 ms +- stems still protected by patience + +### Profile D — Debug / exaggerated + +```text +Base grace timer: 600 ms +Tap extra grace: 250 ms +Finished-word speed-up: 450 ms +Extendable-stem patience: 1000 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words commit very fast +- `ba`, `pre`, `con` wait a long time + +Use this only to understand the system. + +--- + +## 3. HUD labels + +Enable **Experimental → Draw gesture debug points**. + +HUD labels: + +- `FAST Nms · finished word` — complete dictionary word; timer shortened. +- `WAIT Nms · many continuations` — prefix-rich stem; timer lengthened. +- `TIMER Nms · not complete` — normal timer; no complete-word signal yet. +- `INSTANT` / `PAUSE` — Assisted-tier gate decision once enabled. + +If the HUD says `WAIT` for a word you expected to be finished, the dictionary/suggestion signal thinks many continuations are plausible. + +--- + +## 4. Practical tests + +### Test A — tap-only safety + +Input method: **tap only**. + +Use: +- `ba` (saved shortcut if available) +- any short typed word + +Settings: +- **Only auto-finish swiped words: ON** + +Expected: +- no auto-commit +- no Text Expander expansion +- no correction append behavior +- word stays composing until you press space or pick a suggestion + +If this fails, the swipe-only finish gate is broken. + +### Test B — swiped complete words should finish faster + +Input method: **swipe**. + +Try: +- `the` +- `and` +- `I` +- `hello` + +Expected with **Adapt pause to the word ON**: +- HUD says **FAST** +- word commits sooner than with Adapt OFF +- no need to tap space if the grace auto-space settings allow it + +Tuning: +- Too eager → lower **Finished-word speed-up** or raise base grace. +- Too slow → raise **Finished-word speed-up** or lower base grace. + +### Test C — swiped extendable stems should stay open longer + +Input method: **swipe or partial swipe**. + +Try stems: +- `ba` (bad / bar / bat / ball / back / bank) +- `ca` (can / car / cat / call / came) +- `pre` (pretty / press / prefer / previous) +- `con` (continue / control / content / consider) + +Expected: +- HUD says **WAIT** +- word does not auto-finish immediately +- you can keep extending without fighting the timer + +Tuning: +- Still commits too soon → raise **Extendable-stem patience** or base grace. +- Too sticky / never finishes → lower **Extendable-stem patience**. + +### Test D — tap-then-swipe still extends + +Input method: **tap prefix, then swipe rest**. + +Example: +- tap `fire` +- swipe `truck` + +Expected: +- tap prefix does not auto-finish by itself +- after the swipe fragment, the combined word still auto-finishes normally +- it should not become `fire firetruck` + +This verifies that **Only auto-finish swiped words** suppresses only the tap timer, not combining-mode entry. + +### Test E — Adapt ON vs OFF comparison + +Input methods: **swipe**, plus tap-only safety check. + +Words: +- `the` +- `ba` +- `pre` + +Expected: +- Adapt OFF: same pause for all swiped words/stems. +- Adapt ON: complete words faster, prefix-rich stems slower. +- Tap-only words still do not auto-finish when swipe-only finish is ON. + +If you cannot feel a difference: + +```text +Finished-word speed-up = 350 ms +Extendable-stem patience = 700 ms +``` + +### Test F — corrections replace, not append + +Input method: **tap only**. + +1. Misspell a word by tapping. +2. Wait briefly. +3. Pick the correction from the suggestion strip. + +Expected: +- correction replaces the misspelled word +- it does not append a second word + +If this fails, first confirm **Only auto-finish swiped words** is ON. The historical append bug came from the grace timer auto-committing tap words before the pick. + +### Test G — punctuation and deferred spacing + +Input method: **swipe**. + +With **Defer grace space** ON, try: +- `hello.` +- `the,` +- `word?` + +Expected: +- no double spaces +- no space before punctuation +- backspace after a grace commit still removes the right thing + +Remember: this tests **space materialization**, not auto-finish timing. + +--- + +## 5. Recording results + +For each run, note: + +```text +base grace: +tap extra grace: +finished-word speed-up: +extendable-stem patience: +Adapt pause to the word: on/off +Only auto-finish swiped words: on/off +Only auto-space after swipes: on/off +Defer grace space: on/off +input method: tap / swipe / tap-then-swipe +word: +HUD: +result: +``` + +Good tuning notes look like: + +```text +base 550, tap extra 250, speed-up 250, patience 600 +swipe "the" -> FAST 300ms, felt right +swipe "ba" -> WAIT 910ms, still too fast, raised patience +TAP shortcut "ba" -> stayed composing, good +TAP then SWIPE fire+truck -> combined and committed, good +``` diff --git a/docs/badges/download.svg b/docs/badges/download.svg new file mode 100644 index 000000000..ee156f654 --- /dev/null +++ b/docs/badges/download.svg @@ -0,0 +1 @@ +VersionVersionv3.8.5v3.8.5 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg new file mode 100644 index 000000000..df1200edc --- /dev/null +++ b/docs/badges/downloads.svg @@ -0,0 +1 @@ +DownloadsDownloads3069330693 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg new file mode 100644 index 000000000..66a371b67 --- /dev/null +++ b/docs/badges/stars.svg @@ -0,0 +1 @@ +StarsStars488488 diff --git a/fastlane/metadata/android/en-US/changelogs/4000.txt b/fastlane/metadata/android/en-US/changelogs/4000.txt new file mode 100644 index 000000000..910f2974a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4000.txt @@ -0,0 +1,5 @@ +Handwriting input (Standard) — write on a recognition canvas using a downloadable plugin. +Offline AI now runs on-device GGUF models via llama.cpp, with adjustable sampling. +Richer touchpad gestures: word select, word-by-word navigation, clipboard, undo/redo, hold-to-backspace. +Auto-read one-time codes from an incoming SMS as a tappable suggestion. +Regex shortcuts in Text Expander.