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
+
+ EOF
+
+ # Downloads count badge
+ cat > docs/badges/downloads.svg << EOF
+
+ EOF
+
+ # Stars badge
+ cat > docs/badges/stars.svg << EOF
+
+ 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