diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml
deleted file mode 100644
index a3f5715c1..000000000
--- a/.github/workflows/update-badges.yml
+++ /dev/null
@@ -1,78 +0,0 @@
-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/CHANGELOG.md b/CHANGELOG.md
index 566105ea0..2823bd71e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **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.
+- **Dynamic dictionary/plugin downloader** — Standard builds can fetch layout dictionaries, emoji dictionaries, and handwriting plugins on demand.
+- **Selective backup and restore** — backup/restore settings, dictionaries, and AI prompt configuration more granularly.
### Changed
- **Offline AI backend switched from ONNX Runtime to llama.cpp (GGUF).** The Offline build now
@@ -31,11 +33,19 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
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.
+- Standard builds now exclude non-en-US dictionary assets and download optional dictionaries dynamically.
+
+### Fixed
+- **Sticky Shift from upstream handwriting cleanup** — upstream v3.8.6 stopped the hidden handwriting
+ bottom row on every keyboard-frame switch, which globally cancelled the active Shift pointer before
+ release. We keep the upstream handwriting feature but only stop handwriting when it is actually
+ shown. (Upstream bug LeanBitLab/LeanType#186; upstream PR #194.)
### 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
+- Merged **LeanBitLab/LeanType v3.8.8** (from v3.8.3, including v3.8.7 and two post-tag docs/badge
+ commits) — the source of the handwriting, llama.cpp/GGUF, dynamic downloader, touchpad-gesture,
+ SMS-OTP, selective-backup, and dictionary-downloader 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
diff --git a/README.md b/README.md
index 988fcbe81..095bd0754 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
- **🧠 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.
+- **📥 Dynamic Downloader** - Standard builds can download layout dictionaries, emoji dictionaries, and handwriting plugins on demand, keeping the initial app smaller.
- **🪟 Floating Keyboard** - Detach the keyboard into a draggable, resizable window (true OS-level overlay), with an optional persistent mode.
- **⌨️ Dual Toolbar / Split Suggestions** - Split the suggestion strip and toolbar for easier reach.
- **🖱️ Touchpad Mode** - Swipe the spacebar up for a cursor touchpad with sensitivity controls and edge-scroll acceleration, including a full-screen laptop-style mode.
@@ -37,6 +38,8 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
- **🔄 Google Dictionary Import** - Import your personal dictionary words.
- **🔍 Clipboard Search & Undo** - Search clipboard history from the toolbar, undo accidental deletions, and fold pinned items by default.
- **📸 Screenshot Suggestion & Clipboard** - Recently-taken screenshots are offered in the suggestion strip and saved to clipboard history.
+- **✉️ Auto-Read OTP** - Incoming one-time codes can appear in the suggestion strip for quick insertion.
+- **💾 Selective Backup & Restore** - Backup and restore settings, dictionaries, and AI prompt configuration selectively.
- **🔎 Emoji Search** - Search emojis by name. *Requires loading an Emoji Dictionary.*
- **⚙️ Enhanced Customization** - Force auto-capitalization, fine-grained haptics, distinct incognito icon, reorganized settings, and more.
- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI, handwriting), **Offline** (network hard-disabled, offline GGUF model), or **Offline Lite** (no AI, ~20 MB).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8da584599..f16260ef6 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -36,9 +36,7 @@ android {
productFlavors {
create("standard") {
dimension = "privacy"
- }
- create("standardOptimised") {
- dimension = "privacy"
+ minSdk = 23
}
create("offline") {
dimension = "privacy"
@@ -106,7 +104,6 @@ android {
"standard" -> "1"
"offline" -> "2"
"offlinelite" -> "3"
- "standardOptimised" -> "4"
else -> ""
}
if (number.isNotEmpty()) {
@@ -119,13 +116,28 @@ android {
}
// got a little too big for GitHub after some dependency upgrades, so we remove the largest dictionary
androidComponents.onVariants { variant: ApplicationVariant ->
+ val patterns = mutableListOf()
if (variant.buildType == "debug") {
- variant.androidResources.ignoreAssetsPatterns = listOf("main_ro.dict")
+ patterns.add("main_ro.dict")
variant.proguardFiles = emptyList()
//noinspection ProguardAndroidTxtUsage we intentionally use the "normal" file here
variant.proguardFiles.add(project.layout.buildDirectory.file(getDefaultProguardFile("proguard-android.txt").absolutePath))
variant.proguardFiles.add(project.layout.buildDirectory.file(project.buildFile.parent + "/proguard-rules.pro"))
}
+ if (variant.flavorName == "standard") {
+ // ponytail: dynamically find all dict files to ignore in standard flavor except main_en-US.dict
+ val dictsDir = project.file("src/main/assets/dicts")
+ if (dictsDir.exists() && dictsDir.isDirectory) {
+ dictsDir.listFiles()?.forEach { file ->
+ if (file.name.endsWith(".dict") && file.name != "main_en-US.dict") {
+ patterns.add(file.name)
+ }
+ }
+ }
+ }
+ if (patterns.isNotEmpty()) {
+ variant.androidResources.ignoreAssetsPatterns = patterns
+ }
}
}
@@ -194,14 +206,6 @@ android {
// these orphaned strings are harmlessly stripped by R8 during minification.
disable += "ExtraTranslation"
}
-
- sourceSets {
- getByName("standardOptimised") {
- java.srcDirs("src/standard/java")
- res.srcDirs("src/standard/res")
- manifest.srcFile("src/standard/AndroidManifest.xml")
- }
- }
}
dependencies {
@@ -230,8 +234,6 @@ dependencies {
// gemini ai proofreading
"standardImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0")
"standardImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // for encrypted API key storage
- "standardOptimisedImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0")
- "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06")
// local llm proofreading (offline)
"offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0")
@@ -248,7 +250,6 @@ dependencies {
// 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"))
@@ -267,11 +268,9 @@ dependencies {
"runTestsImplementation"("androidx.compose.ui:ui-test-manifest")
}
-// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds (except for standardOptimised)
+// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds
tasks.configureEach {
if (name.contains("ArtProfile", ignoreCase = true)) {
- if (!name.contains("StandardOptimised", ignoreCase = true)) {
- enabled = false
- }
+ enabled = false
}
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 50d6ba6d4..97b947a73 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -27,6 +27,8 @@
# Keep java-llama.cpp classes
-keep class de.kherud.llama.** { *; }
+-keep class org.nehuatl.llamacpp.** { *; }
+
# Fix correct service name
@@ -36,3 +38,20 @@
-dontwarn com.google.api.client.**
-dontwarn java.lang.management.**
-dontwarn org.joda.time.**
+
+# Keep handwriting plugin interface and listener to prevent parameter removal/signature optimization
+-keep interface helium314.keyboard.latin.handwriting.HandwritingRecognizer {
+ ;
+}
+-keep interface helium314.keyboard.latin.handwriting.ModelDownloadListener {
+ ;
+}
+
+# Keep ML Kit, GMS Tasks, and Firebase components for handwriting plugin dynamic linkage
+-keep class com.google.mlkit.** { *; }
+-keep class com.google.android.gms.tasks.** { *; }
+-keep class com.google.firebase.components.** { *; }
+
+# Keep Kotlin standard library for dynamically loaded plugins
+# ponytail: keep kotlin stdlib classes to prevent NoSuchMethodError in plugin loading
+-keep class kotlin.** { *; }
diff --git a/app/src/main/assets/layouts/number_row/number_row.json b/app/src/main/assets/layouts/number_row/number_row.json
index 3ad11861a..8a5ef88aa 100644
--- a/app/src/main/assets/layouts/number_row/number_row.json
+++ b/app/src/main/assets/layouts/number_row/number_row.json
@@ -1,44 +1,14 @@
-[
- [
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "!" },
- "default": { "label": "1", "popup": { "relevant": [{ "label": "¹" }, { "label": "½" }, { "label": "⅓" }, { "label": "¼" }, { "label": "⅛" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "@" },
- "default": { "label": "2", "popup": { "relevant": [{ "label": "²" }, { "label": "⅔" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "#" },
- "default": { "label": "3", "popup": { "relevant": [{ "label": "³" }, { "label": "¾" }, { "label": "⅜" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "$" },
- "default": { "label": "4", "popup": { "relevant": [{ "label": "⁴" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "%" },
- "default": { "label": "5", "popup": { "relevant": [{ "label": "⁵" }, { "label": "⅝" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "^" },
- "default": { "label": "6", "popup": { "relevant": [{ "label": "⁶" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "&" },
- "default": { "label": "7", "popup": { "relevant": [{ "label": "⁷" }, { "label": "⅞" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "*" },
- "default": { "label": "8", "popup": { "relevant": [{ "label": "⁸" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": "(" },
- "default": { "label": "9", "popup": { "relevant": [{ "label": "⁹" }] } }
- },
- { "$": "shift_state_selector",
- "manualOrLocked": { "label": ")" },
- "default": { "label": "0", "popup": { "relevant": [{ "label": "⁰" }, { "label": "ⁿ" }, { "label": "∅" }] } }
- }
- ]
- ]
\ No newline at end of file
+[
+ [
+ { "label": "1", "popup": { "relevant": [{ "label": "!" }, { "label": "¹" }, { "label": "½" }, { "label": "⅓" }, { "label": "¼" }, { "label": "⅛" }] } },
+ { "label": "2", "popup": { "relevant": [{ "label": "@" }, { "label": "²" }, { "label": "⅔" }] } },
+ { "label": "3", "popup": { "relevant": [{ "label": "#" }, { "label": "³" }, { "label": "¾" }, { "label": "⅜" }] } },
+ { "label": "4", "popup": { "relevant": [{ "label": "$" }, { "label": "⁴" }] } },
+ { "label": "5", "popup": { "relevant": [{ "label": "%" }, { "label": "⁵" }, { "label": "⅝" }] } },
+ { "label": "6", "popup": { "relevant": [{ "label": "^" }, { "label": "⁶" }] } },
+ { "label": "7", "popup": { "relevant": [{ "label": "&" }, { "label": "⁷" }, { "label": "⅞" }] } },
+ { "label": "8", "popup": { "relevant": [{ "label": "*" }, { "label": "⁸" }] } },
+ { "label": "9", "popup": { "relevant": [{ "label": "(" }, { "label": "⁹" }] } },
+ { "label": "0", "popup": { "relevant": [{ "label": ")" }, { "label": "⁰" }, { "label": "ⁿ" }, { "label": "∅" }] } }
+ ]
+]
diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java
index e1151832f..6d955ffeb 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java
@@ -376,6 +376,8 @@ private void setupTouchSurface() {
mIsTwoFingerTap = false;
removeCallbacks(mTwoFingerLongPressRunnable);
mIsTwoFingerLongPress = false;
+ mTwoFingerTapCount = 0;
+ removeCallbacks(mTwoFingerTapRunnable);
if (mSelectionMode) {
mSelectionMode = false;
applySurfaceColor();
@@ -385,6 +387,7 @@ private void setupTouchSurface() {
case MotionEvent.ACTION_CANCEL:
android.util.Log.i("TouchpadView", "ACTION_CANCEL");
mIsDragging = false;
+ stopEdgeScrolling();
mIsTwoFingerScroll = false;
mIsTwoFingerTap = false;
removeCallbacks(mTwoFingerLongPressRunnable);
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 b83c53b10..6a9770945 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java
@@ -233,6 +233,7 @@ private void updateState(@NonNull RecyclerView recyclerView, long categoryId) {
private EmojiSearchAdapter mSearchAdapter;
private EditText mSearchBar;
private boolean mInSearchMode = false;
+ private boolean mIsDownloadingEmojiDict = false;
private KeyboardActionListener mOriginalActionListener;
private EditorInfo mEditorInfo;
@@ -496,13 +497,14 @@ public void afterTextChanged(Editable s) {
if (Settings.getValues().mSplitToolbar) {
// Do not add results to this row, they go to SuggestionStripView
mSearchAdapter = null;
+ updateSplitToolbarEmojiSuggestions();
} else if (sDictionaryFacilitator == null) {
Button downloadBtn = new Button(ctx);
downloadBtn.setText("Download Dictionary");
downloadBtn.setTextSize(12); // Keep it small to fit
downloadBtn.setAllCaps(false);
downloadBtn.setOnClickListener(v -> {
- if ("standard".equals(BuildConfig.FLAVOR) || "standardOptimised".equals(BuildConfig.FLAVOR)) {
+ if ("standard".equals(BuildConfig.FLAVOR)) {
downloadEmojiDictionary();
downloadBtn.setText("Downloading...");
downloadBtn.setEnabled(false);
@@ -762,7 +764,11 @@ private void performSearch(String query) {
mSearchAdapter.submitList(java.util.Collections.emptyList());
// In split mode, restore recents on suggestion bar when search is empty
if (Settings.getValues().mSplitToolbar) {
- populateSuggestionBarWithRecents();
+ if (sDictionaryFacilitator == null) {
+ updateSplitToolbarEmojiSuggestions();
+ } else {
+ populateSuggestionBarWithRecents();
+ }
}
return;
}
@@ -914,7 +920,8 @@ private void addRecentKey(final Key key) {
if (mPager != null && mPager.getAdapter() != null) {
mPager.getAdapter().notifyItemChanged(mEmojiCategory.getRecentTabId());
}
- if (split) {
+ // ponytail: only update suggestion bar if the emoji view is actually visible
+ if (split && isShown()) {
populateSuggestionBarWithRecents();
}
}
@@ -934,7 +941,8 @@ public void addRecentKey(final String emoji) {
if (mPager != null && mPager.getAdapter() != null) {
mPager.getAdapter().notifyItemChanged(mEmojiCategory.getRecentTabId());
}
- if (split) {
+ // ponytail: only update suggestion bar if the emoji view is actually visible
+ if (split && isShown()) {
populateSuggestionBarWithRecents();
}
}
@@ -1023,6 +1031,35 @@ private void pushEmojisToSuggestionBar(java.util.List emojis) {
});
}
+ private void updateSplitToolbarEmojiSuggestions() {
+ SuggestionStripView stripView = KeyboardSwitcher.getInstance().getSuggestionStripView();
+ if (stripView == null)
+ return;
+
+ if (sDictionaryFacilitator == null) {
+ // ponytail: show download button on suggestion strip in split mode if dictionary is missing
+ stripView.setEmojiDownloadButton(() -> {
+ if ("standard".equals(BuildConfig.FLAVOR)) {
+ downloadEmojiDictionary();
+ mIsDownloadingEmojiDict = true;
+ updateSplitToolbarEmojiSuggestions();
+ } else {
+ Context ctx = getContext();
+ Intent intent = new Intent(ctx, SettingsActivity.class);
+ intent.putExtra("screen", "dictionaries");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ ctx.startActivity(intent);
+ }
+ }, mIsDownloadingEmojiDict);
+ } else {
+ if (mSearchBar != null && !TextUtils.isEmpty(mSearchBar.getText())) {
+ performSearch(mSearchBar.getText().toString());
+ } else {
+ populateSuggestionBarWithRecents();
+ }
+ }
+ }
+
public void setKeyboardActionListener(final KeyboardActionListener listener) {
mKeyboardActionListener = listener;
}
@@ -1154,9 +1191,10 @@ private void downloadEmojiDictionary() {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Toast.makeText(getContext(), "Emoji dictionary installed!", Toast.LENGTH_SHORT).show();
initDictionaryFacilitator();
+ mIsDownloadingEmojiDict = false;
if (mInSearchMode) {
+ // ponytail: close search mode automatically on successful dictionary download
stopSearchMode();
- startSearchMode();
}
});
} else {
@@ -1166,6 +1204,7 @@ private void downloadEmojiDictionary() {
android.util.Log.e("EmojiSearch", "Failed to download dictionary", e);
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
Toast.makeText(getContext(), "Failed to download dictionary", Toast.LENGTH_SHORT).show();
+ mIsDownloadingEmojiDict = false;
if (mInSearchMode) {
stopSearchMode();
startSearchMode();
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
index 9408fe0af..384bf644e 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
@@ -73,6 +73,8 @@ class KeyboardState(private val switchActions: SwitchActions) {
// For handling double tap.
private var isInAlphabetUnshiftedFromShifted = false
private var isInDoubleTapShiftKey = false
+ private var lastShiftPressTime = 0L
+
private val savedKeyboardState = SavedKeyboardState()
@@ -515,8 +517,11 @@ class KeyboardState(private val switchActions: SwitchActions) {
shiftKeyState.onPress()
return
}
- isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout
+ val now = android.os.SystemClock.uptimeMillis()
+ isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout && (now - lastShiftPressTime > 100)
+ lastShiftPressTime = now
if (isInDoubleTapShiftKey) {
+
if (alphabetShiftState.isManualShifted || isInAlphabetUnshiftedFromShifted) {
// Shift key has been double tapped while in manual shifted or automatic shifted state.
setShiftLocked(true)
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java
index e47a42361..c6a3fb374 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java
@@ -163,10 +163,10 @@ public boolean isTypingState() {
@Override
public void startDoubleTapShiftKeyTimer() {
- sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY),
- ViewConfiguration.getDoubleTapTimeout());
+ sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY), 300);
}
+
@Override
public void cancelDoubleTapShiftKeyTimer() {
removeMessages(MSG_DOUBLE_TAP_SHIFT_KEY);
@@ -218,6 +218,7 @@ public void postDismissGestureFloatingPreviewText(final long delay) {
public void cancelAllMessages() {
cancelAllKeyTimers();
+ cancelDoubleTapShiftKeyTimer();
cancelAllUpdateBatchInputTimers();
removeMessages(MSG_DISMISS_KEY_PREVIEW);
removeMessages(MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT);
diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
index 8d69b59a8..28bbc9c8d 100644
--- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
+++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
@@ -995,7 +995,11 @@ private class DictionaryGroup(
private var compiledBlacklistPatterns: List = emptyList()
private fun rebuildCompiledPatterns() {
- compiledBlacklistPatterns = blacklist.map { pattern ->
+ rebuildCompiledPatterns(blacklist)
+ }
+
+ private fun rebuildCompiledPatterns(patterns: Collection) {
+ compiledBlacklistPatterns = patterns.map { pattern ->
try {
Regex(pattern, RegexOption.IGNORE_CASE)
} catch (e: Exception) {
@@ -1003,7 +1007,6 @@ private class DictionaryGroup(
}
}
}
-
private val blacklist = hashSetOf().apply {
val file = blacklistFile
if (file == null) return@apply
@@ -1022,7 +1025,7 @@ private class DictionaryGroup(
}
}
addAll(loadedWords)
- rebuildCompiledPatterns()
+ rebuildCompiledPatterns(this@apply)
} catch (e: IOException) {
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 b2577c3c4..589707036 100644
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
@@ -295,6 +295,10 @@ public void handleMessage(@NonNull final Message msg) {
}
public void postUpdateSuggestionStrip(final int inputStyle) {
+ final LatinIME latinIme = getOwnerInstance(); // ponytail: skip during handwriting
+ if (latinIme != null && latinIme.mKeyboardSwitcher.isHandwritingShowing()) {
+ return;
+ }
sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle,
0 /* ignored */), mDelayInMillisecondsToUpdateSuggestions);
}
@@ -308,6 +312,9 @@ public void postResumeSuggestions(final boolean shouldDelay) {
if (latinIme == null) {
return;
}
+ if (latinIme.mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: skip during handwriting
+ return;
+ }
if (!latinIme.mSettings.getCurrent().needsToLookupSuggestions()) {
return;
}
@@ -1682,8 +1689,11 @@ public void setSuggestions(final SuggestedWords suggestedWords) {
// should be fine, as there will be another suggestion in a few ms
// (but not a great style to avoid this visual glitch, maybe revert this commit
// and replace with sth better)
- if (suggestedWords.mInputStyle != SuggestedWords.INPUT_STYLE_UPDATE_BATCH)
+ if (mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: bypass neutral strip/punc lookup
+ setSuggestedWords(suggestedWords);
+ } else if (suggestedWords.mInputStyle != SuggestedWords.INPUT_STYLE_UPDATE_BATCH) {
setNeutralSuggestionStrip();
+ }
} else {
setSuggestedWords(suggestedWords);
}
@@ -1768,6 +1778,9 @@ public boolean tryShowClipboardSuggestion() {
// and there is a selection of text or it's the start of a line.
@Override
public void setNeutralSuggestionStrip() {
+ if (mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: do not override/clear handwriting suggestions
+ return;
+ }
final SettingsValues currentSettings = mSettings.getCurrent();
if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) {
// an external (OTP or clipboard) suggestion has been set
diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt
index 308a0ecdf..b50de2392 100644
--- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt
+++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt
@@ -14,4 +14,6 @@ interface HandwritingRecognizer {
fun isLanguageReady(language: String): Boolean
fun downloadModel(language: String, listener: ModelDownloadListener)
fun recognize(strokes: List): List?
+ // ponytail: allow deleting downloaded models, default false for backward compatibility
+ fun removeModel(language: String): Boolean = false
}
diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
index 39a6227e3..56331ff57 100644
--- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
+++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
@@ -8,6 +8,7 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
+import android.widget.ProgressBar
import android.graphics.drawable.GradientDrawable
import helium314.keyboard.keyboard.KeyboardActionListener
import helium314.keyboard.keyboard.KeyboardId
@@ -41,6 +42,8 @@ class HandwritingView @JvmOverloads constructor(
private lateinit var clearButton: ImageButton
private lateinit var canvas: HandwritingCanvas
private lateinit var bottomRowKeyboard: MainKeyboardView
+ private lateinit var downloadProgress: ProgressBar
+ private var toolbar: View? = null // ponytail: track toolbar
private var keyboardActionListener: KeyboardActionListener? = null
private var editorInfo: EditorInfo? = null
@@ -54,6 +57,8 @@ class HandwritingView @JvmOverloads constructor(
clearButton = findViewById(R.id.handwriting_clear_button)
canvas = findViewById(R.id.handwriting_canvas)
bottomRowKeyboard = findViewById(R.id.handwriting_bottom_row_keyboard)
+ downloadProgress = findViewById(R.id.handwriting_download_progress)
+ toolbar = findViewById(R.id.handwriting_toolbar)
clearButton.setOnClickListener {
clearCanvasAndComposition()
@@ -80,9 +85,9 @@ class HandwritingView @JvmOverloads constructor(
this.currentLanguage = language
val colors = Settings.getValues().mColors
- val toolbar = findViewById(R.id.handwriting_toolbar)
- if (toolbar != null) {
- colors.setBackground(toolbar, ColorType.MAIN_BACKGROUND)
+ toolbar?.let {
+ colors.setBackground(it, ColorType.MAIN_BACKGROUND)
+ it.visibility = View.GONE // ponytail: hide by default to avoid duplicate toolbar/X buttons
}
colors.setBackground(canvas, ColorType.MAIN_BACKGROUND)
@@ -91,6 +96,7 @@ class HandwritingView @JvmOverloads constructor(
canvas.setStrokeColor(colors.get(ColorType.KEY_TEXT))
languageLabel.text = language
+ downloadProgress.visibility = View.GONE
// Setup bottom row keyboard
bottomRowKeyboard.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn)
@@ -137,17 +143,25 @@ class HandwritingView @JvmOverloads constructor(
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)
+ // ponytail: download plugin directly on standard flavor, otherwise go to Settings
+ if ("standard" == helium314.keyboard.latin.BuildConfig.FLAVOR) {
+ button.text = "Download Plugin"
+ button.setOnClickListener {
+ downloadPlugin(button)
+ }
+ } else {
+ 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)
}
- KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0)
}
}
} else {
@@ -161,19 +175,27 @@ class HandwritingView @JvmOverloads constructor(
val isReady = recognizer.isLanguageReady(language)
mainHandler.post {
if (!isReady) {
+ toolbar?.visibility = View.VISIBLE // ponytail: show for download progress
languageLabel.text = "$language (Downloading...)"
+ downloadProgress.visibility = View.VISIBLE
+ downloadProgress.progress = 0
recognizer.downloadModel(language, object : ModelDownloadListener {
override fun onProgress(progress: Float) {
mainHandler.post {
- languageLabel.text = "$language (Downloading ${"%.0f".format(progress * 100)}%)"
+ val percent = (progress * 100).toInt()
+ languageLabel.text = "$language (Downloading $percent%)"
+ downloadProgress.progress = percent
}
}
override fun onComplete(success: Boolean) {
mainHandler.post {
+ downloadProgress.visibility = View.GONE
if (success) {
+ toolbar?.visibility = View.GONE // ponytail: hide when done
languageLabel.text = language
android.widget.Toast.makeText(context, "Handwriting model downloaded", android.widget.Toast.LENGTH_SHORT).show()
} else {
+ toolbar?.visibility = View.VISIBLE
languageLabel.text = "$language (Download failed)"
android.widget.Toast.makeText(context, "Failed to download handwriting model", android.widget.Toast.LENGTH_LONG).show()
}
@@ -181,7 +203,9 @@ class HandwritingView @JvmOverloads constructor(
}
})
} else {
+ toolbar?.visibility = View.GONE // ponytail: hide when already downloaded
languageLabel.text = language
+ downloadProgress.visibility = View.GONE
}
}
}
@@ -365,4 +389,76 @@ class HandwritingView @JvmOverloads constructor(
override fun onMoveDeletePointer(steps: Int) { keyboardActionListener?.onMoveDeletePointer(steps) }
override fun onUpWithDeletePointerActive() { keyboardActionListener?.onUpWithDeletePointerActive() }
override fun resetMetaState() { keyboardActionListener?.resetMetaState() }
+
+ // ponytail: downloads the latest handwriting plugin apk, imports it and updates overlay visibility
+ private fun downloadPlugin(button: TextView) {
+ button.text = "Downloading..."
+ button.isEnabled = false
+ android.widget.Toast.makeText(context, "Downloading Handwriting Plugin...", android.widget.Toast.LENGTH_SHORT).show()
+
+ java.util.concurrent.Executors.newSingleThreadExecutor().execute {
+ try {
+ val urlStr = "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk"
+ var url = java.net.URL(urlStr)
+ var conn = url.openConnection() as java.net.HttpURLConnection
+ conn.instanceFollowRedirects = true
+ conn.setRequestProperty("User-Agent", "HeliboardL")
+ conn.connect()
+
+ var redirectConn = conn
+ var status = redirectConn.responseCode
+ var redirectCount = 0
+ while ((status == java.net.HttpURLConnection.HTTP_MOVED_TEMP || status == java.net.HttpURLConnection.HTTP_MOVED_PERM || status == java.net.HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) {
+ val newUrl = redirectConn.getHeaderField("Location")
+ redirectConn.disconnect()
+ val nextUrl = java.net.URL(newUrl)
+ redirectConn = nextUrl.openConnection() as java.net.HttpURLConnection
+ redirectConn.setRequestProperty("User-Agent", "HeliboardL")
+ redirectConn.connect()
+ status = redirectConn.responseCode
+ redirectCount++
+ }
+
+ if (status != java.net.HttpURLConnection.HTTP_OK) {
+ throw java.io.IOException("Server returned HTTP $status")
+ }
+
+ val tempFile = java.io.File(context.cacheDir, "temp_handwriting_plugin.apk")
+ redirectConn.inputStream.use { input ->
+ java.io.FileOutputStream(tempFile).use { output ->
+ input.copyTo(output)
+ }
+ }
+ redirectConn.disconnect()
+
+ val success = HandwritingLoader.importPlugin(context, android.net.Uri.fromFile(tempFile))
+ tempFile.delete()
+
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ button.isEnabled = true
+ if (success) {
+ button.text = "Success"
+ android.widget.Toast.makeText(context, "Handwriting plugin installed!", android.widget.Toast.LENGTH_SHORT).show()
+ val overlay = findViewById(R.id.handwriting_plugin_overlay)
+ overlay?.visibility = View.GONE
+ editorInfo?.let { ei ->
+ keyboardActionListener?.let { listener ->
+ startHandwriting(ei, listener, currentLanguage)
+ }
+ }
+ } else {
+ button.text = "Download Plugin"
+ android.widget.Toast.makeText(context, "Failed to install plugin", android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("HandwritingView", "Failed to download plugin", e)
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ button.isEnabled = true
+ button.text = "Download Plugin"
+ android.widget.Toast.makeText(context, "Download failed: ${e.localizedMessage}", android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
}
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 79c939f1b..3ea61ce4d 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -564,15 +564,6 @@ 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
@@ -2506,28 +2497,6 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
mSpaceState = SpaceState.NONE;
mDeleteCount++;
- 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.deleteSurroundingText(beforeLen, afterLen);
- mConnection.commitText(mLastShortcutText, 1);
- mLastExpandedText = null;
- mLastShortcutText = null;
- mLastExpandedCursorPosition = -1;
- mLastExpandedCursorOffset = -1;
- return;
- }
- }
- }
-
if (mLastExpandedText != null && !event.isKeyRepeat()) {
final int expectedCursor = mConnection.getExpectedSelectionEnd();
if (expectedCursor == mLastExpandedCursorPosition) {
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 db2701b7b..de0e41660 100644
--- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt
+++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt
@@ -66,6 +66,10 @@ import helium314.keyboard.latin.utils.removeFirst
import helium314.keyboard.latin.utils.removePinnedKey
import helium314.keyboard.latin.utils.setToolbarButtonsActivatedState
import helium314.keyboard.latin.utils.setToolbarButtonsActivatedStateOnPrefChange
+import helium314.keyboard.latin.utils.isMainDictionaryMissing
+import helium314.keyboard.latin.utils.showMissingDictionaryComposeDialog
+import helium314.keyboard.latin.utils.SubtypeSettings
+import helium314.keyboard.latin.utils.locale
import helium314.keyboard.settings.SettingsWithoutKey
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
@@ -128,6 +132,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
private val pinnedKeys: ViewGroup = findViewById(R.id.pinned_keys)
private val suggestionsStrip: ViewGroup = findViewById(R.id.suggestions_strip)
private val toolbarExpandKey = findViewById(R.id.suggestions_strip_toolbar_key)
+ private var dictDownloadButton: ImageButton? = null
private val toolbarArrowIcon = KeyboardIconsSet.instance.getNewDrawable(KeyboardIconsSet.NAME_TOOLBAR_KEY, context)
private val defaultToolbarBackground: Drawable = toolbarExpandKey.background
private val enabledToolKeyBackground = GradientDrawable()
@@ -165,7 +170,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
} else {
isToolbarManuallyOpen = settingsValues.mAutoShowToolbar
}
-
+
val colors = settingsValues.mColors
// expand key
@@ -198,12 +203,12 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
if (Settings.getValues().mSplitToolbar) {
val stripHeight = resources.getDimensionPixelSize(R.dimen.config_suggestions_strip_height)
-
+
val wrapper = findViewById(R.id.suggestions_strip_wrapper)
-
+
// Set wrapper to vertical
wrapper.orientation = LinearLayout.VERTICAL
-
+
// Create toolbar row for Expand Key, Toolbar, Pinned Keys
val toolbarRow = LinearLayout(context)
toolbarRow.orientation = LinearLayout.HORIZONTAL
@@ -211,40 +216,40 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
LinearLayout.LayoutParams.MATCH_PARENT,
stripHeight
)
-
+
// Remove views from wrapper
wrapper.removeView(toolbarExpandKey)
wrapper.removeView(toolbarContainer)
wrapper.removeView(pinnedKeys)
-
+
// Set new layout params when adding to toolbarRow
val expandKeyParams = LinearLayout.LayoutParams(
toolbarExpandKey.layoutParams.width,
LinearLayout.LayoutParams.MATCH_PARENT
)
toolbarExpandKey.layoutParams = expandKeyParams
-
+
val toolbarParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.MATCH_PARENT,
1f // weight
)
toolbarContainer.layoutParams = toolbarParams
-
+
val pinnedParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
pinnedKeys.layoutParams = pinnedParams
-
+
// Add views to toolbar row
toolbarRow.addView(toolbarExpandKey)
toolbarRow.addView(toolbarContainer)
toolbarRow.addView(pinnedKeys)
-
+
// Add toolbar row to wrapper at the START (Top) - Toolbar at top, Suggestions at bottom
wrapper.addView(toolbarRow, 0)
-
+
// Set suggestions strip params - use weight to fill remaining space
val suggestionsParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@@ -265,7 +270,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val stripHeight = resources.getDimensionPixelSize(R.dimen.config_suggestions_strip_height)
val split = Settings.getValues().mSplitToolbar
-
+
val newHeightSpec = if (split) {
MeasureSpec.makeMeasureSpec(stripHeight * 2, MeasureSpec.EXACTLY)
} else {
@@ -443,7 +448,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
fun showLoadingAnimation() {
if (isLoadingAnimationActive) return
isLoadingAnimationActive = true
-
+
// Set loading border on the whole toolbar view
this.foreground = loadingBorderDrawable
@@ -457,10 +462,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
Settings.getValues().mColors.setColor(proofreadKey, ColorType.TOOL_BAR_KEY)
}
}
-
+
// Get accent color from theme (GESTURE_TRAIL is the accent color)
- val accentColor = Settings.getValues().mColors.get(ColorType.GESTURE_TRAIL)
-
+ val accentColor = Settings.getValues().mColors.get(ColorType.GESTURE_TRAIL)
+
// Create pulse animation
loadingAnimator = ValueAnimator.ofFloat(0.25f, 1f).apply {
duration = 800
@@ -481,7 +486,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
fun hideLoadingAnimation() {
if (!isLoadingAnimationActive) return
isLoadingAnimationActive = false
-
+
loadingAnimator?.cancel()
loadingAnimator = null
loadingBorderDrawable.setStroke(4, Color.TRANSPARENT)
@@ -504,10 +509,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) {
setToolbarButtonsActivatedStateOnPrefChange(pinnedKeys, key)
setToolbarButtonsActivatedStateOnPrefChange(toolbar, key)
- if (key == Settings.PREF_PINNED_TOOLBAR_KEYS
- || key == Settings.PREF_TOOLBAR_KEYS
- || key == Settings.PREF_QUICK_PIN_TOOLBAR_KEYS
- || key == Settings.PREF_AUTO_HIDE_PINNED_KEYS
+ if (key == Settings.PREF_PINNED_TOOLBAR_KEYS
+ || key == Settings.PREF_TOOLBAR_KEYS
+ || key == Settings.PREF_QUICK_PIN_TOOLBAR_KEYS
+ || key == Settings.PREF_AUTO_HIDE_PINNED_KEYS
|| key == Settings.PREF_SPLIT_TOOLBAR
|| key == "pref_custom_ai_show_tags_on_toolbar"
|| key?.startsWith("pref_custom_ai_tag_") == true) {
@@ -550,7 +555,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
if (isExternalSuggestionVisible) {
return false
}
-
+
// In split mode, don't intercept touches on the top row (toolbar row)
// to prevent accidentally cancelling long presses on toolbar buttons.
if (Settings.getValues().mSplitToolbar) {
@@ -838,7 +843,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT
).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
-
+
button.setOnClickListener {
// Set the selected language and start translation
context.prefs().edit().apply {
@@ -902,7 +907,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
val hideToolbarKeys = isDeviceLocked(context)
// Keep click listener active in split mode (though key is hidden, better to leave logic clean)
toolbarExpandKey.setOnClickListener(if (hideToolbarKeys || !toolbarIsExpandable) null else this)
-
+
if (split) {
toolbarExpandKey.isVisible = false
pinnedKeys.isVisible = false // Hide pinned keys completely in split mode
@@ -918,6 +923,48 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
// This prevents conflicts with auto-hide pinned keys logic
layoutHelper.setSuggestionsCountInStrip(3)
}
+
+ // ponytail: show/hide dictionary download button if dictionary is missing
+ if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") {
+ val currentLocale = SubtypeSettings.getSelectedSubtype(context.prefs()).locale()
+ if (isMainDictionaryMissing(context, currentLocale) && !hideToolbarKeys) {
+ if (dictDownloadButton == null) {
+ dictDownloadButton = ImageButton(context, null, R.attr.suggestionWordStyle).apply {
+ scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
+ val padding = 6.dpToPx(resources)
+ setPadding(padding, padding, padding, padding)
+ setImageResource(R.drawable.ic_dictionary)
+ contentDescription = context.getString(R.string.download)
+ setOnClickListener {
+ val token = this.windowToken
+ if (token != null) {
+ showMissingDictionaryComposeDialog(context, currentLocale, token) {
+ updateKeys()
+ }
+ }
+ }
+ }
+ val toolbarHeight = min(toolbarExpandKey.layoutParams.height, resources.getDimension(R.dimen.config_suggestions_strip_height).toInt())
+ dictDownloadButton?.layoutParams = LinearLayout.LayoutParams(toolbarHeight, toolbarHeight).apply {
+ gravity = android.view.Gravity.CENTER_VERTICAL
+ }
+
+ val wrapper = findViewById(R.id.suggestions_strip_wrapper)
+ val expandIndex = wrapper.indexOfChild(toolbarExpandKey)
+ wrapper.addView(dictDownloadButton, expandIndex + 1)
+ }
+ val colors = Settings.getValues().mColors
+ colors.setColor(dictDownloadButton!!, ColorType.TOOL_BAR_KEY)
+ dictDownloadButton?.setBackgroundResource(R.drawable.toolbar_key_background)
+ colors.setColor(dictDownloadButton!!.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND)
+ dictDownloadButton?.isVisible = true
+ } else {
+ dictDownloadButton?.isVisible = false
+ }
+ } else {
+ dictDownloadButton?.isVisible = false
+ }
+
isExternalSuggestionVisible = false
}
@@ -951,7 +998,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
val pinnedKeysList = getPinnedToolbarKeys(context.prefs())
val mToolbarMode = Settings.getValues().mToolbarMode
val isSplitToolbar = Settings.getValues().mSplitToolbar
-
+
// Toolbar keys setup
// Always populate toolbar keys if mode allows, visibility handled in updateKeys
if (mToolbarMode == ToolbarMode.TOOLBAR_KEYS || mToolbarMode == ToolbarMode.EXPANDABLE) {
@@ -969,7 +1016,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
toolbar.addView(button)
}
}
-
+
// Only draw pinned keys if not in split mode
if (!isSplitToolbar && !Settings.getValues().mSuggestionStripHiddenPerUserSettings) {
for (pinnedKey in pinnedKeysList) {
@@ -997,10 +1044,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
return
}
suggestionsStrip.isVisible = true
-
+
val PLACEHOLDER_TAG = "PLACEHOLDER_VIEW"
val placeholder = suggestionsStrip.findViewWithTag(PLACEHOLDER_TAG)
-
+
// Check if there are any visible suggestions with actual text content
var hasRealSuggestions = false
for (i in 0 until suggestionsStrip.childCount) {
@@ -1021,20 +1068,20 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
placeholderContainer.tag = PLACEHOLDER_TAG
placeholderContainer.orientation = LinearLayout.HORIZONTAL
placeholderContainer.layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
-
+
// Random suggestion words to display
val randomSuggestions = listOf(
"the", "and", "for", "you", "with",
"have", "this", "from", "will", "can",
"hello", "thanks", "please", "okay", "good"
).shuffled().take(5)
-
+
val colors = Settings.getValues().mColors
val customTypeface = Settings.getInstance().customTypeface
-
+
randomSuggestions.forEach { word ->
val suggestionView = TextView(context, null, R.attr.suggestionWordStyle)
suggestionView.text = word
@@ -1044,7 +1091,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
suggestionView.typeface = customTypeface
colors.setBackground(suggestionView, ColorType.STRIP_BACKGROUND)
suggestionView.setTextColor(colors.get(ColorType.KEY_TEXT))
-
+
val params = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.MATCH_PARENT,
@@ -1053,7 +1100,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
suggestionView.layoutParams = params
placeholderContainer.addView(suggestionView)
}
-
+
suggestionsStrip.addView(placeholderContainer)
}
}
@@ -1098,11 +1145,11 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
if (customTypeface != null) emojiView.typeface = customTypeface
emojiView.gravity = android.view.Gravity.CENTER
emojiView.setPadding(
- 8.dpToPx(resources), 2.dpToPx(resources),
+ 8.dpToPx(resources), 2.dpToPx(resources),
8.dpToPx(resources), 2.dpToPx(resources)
)
emojiView.layoutParams = LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
emojiView.setOnClickListener {
@@ -1118,6 +1165,32 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
suggestionsStrip.isVisible = true
}
+ /**
+ * ponytail: Shows a download button in the suggestion strip (used in split toolbar mode).
+ * @param onClick Callback when the download button is tapped
+ * @param isDownloading Whether the dictionary is currently downloading
+ */
+ fun setEmojiDownloadButton(onClick: java.lang.Runnable, isDownloading: Boolean) {
+ if (!Settings.getValues().mSplitToolbar) return
+ isShowingEmojiSuggestions = true
+ suggestionsStrip.removeAllViews()
+
+ val btn = android.widget.Button(context)
+ btn.text = if (isDownloading) "Downloading..." else "Download Dictionary"
+ btn.textSize = 12f
+ btn.isAllCaps = false
+ btn.isEnabled = !isDownloading
+ btn.layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ btn.setOnClickListener {
+ onClick.run()
+ }
+ suggestionsStrip.addView(btn)
+ suggestionsStrip.isVisible = true
+ }
+
/**
* Clears emoji suggestions and restores normal suggestion strip state.
*/
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
index 9e19a0ac6..667a12c84 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
@@ -3,12 +3,25 @@
package helium314.keyboard.latin.utils
import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
import androidx.core.content.edit
+import android.os.IBinder
import helium314.keyboard.compat.locale
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.Links
@@ -16,16 +29,33 @@ import helium314.keyboard.latin.common.LocaleUtils
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.settings.Defaults
import helium314.keyboard.latin.settings.Settings
+import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
+import helium314.keyboard.settings.dialogs.ConfirmationDialogContent
import java.io.File
import java.util.Locale
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
fun getDictionaryLocales(context: Context): MutableSet {
val locales = HashSet()
- // get cached dictionaries: extracted or user-added dictionaries
+ // ponytail: get cached dictionaries: extracted or user-added/downloaded dictionaries
DictionaryInfoUtils.getCacheDirectories(context).forEach { directory ->
- if (!hasAnythingOtherThanExtractedMainDictionary(directory)) return@forEach
+ if (!hasAnythingOtherThanExtractedMainDictionary(context, directory)) return@forEach
val locale = DictionaryInfoUtils.getWordListIdFromFileName(directory.name).constructLocale()
locales.add(locale)
}
@@ -36,12 +66,19 @@ fun getDictionaryLocales(context: Context): MutableSet {
locales.add(DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dictionary))
}
}
+ // ponytail: include enabled locales and multilingual secondary locales
+ val enabled = SubtypeSettings.getEnabledSubtypes()
+ enabled.forEach { subtype ->
+ locales.add(subtype.locale())
+ getSecondaryLocales(subtype.extraValue).forEach { locales.add(it) }
+ }
return locales
}
@Composable
-fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale) {
- val prefs = LocalContext.current.prefs()
+fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline: Boolean = false) {
+ val context = LocalContext.current
+ val prefs = context.prefs()
if (prefs.getBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, Defaults.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG)) {
onDismissRequest()
return
@@ -52,16 +89,52 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale) {
val dictionaryLink = stringResource(R.string.dictionary_link_text).withHtmlLink(dictUrl)
val message = stringResource(R.string.no_dictionary_message, repositoryLink, locale.toString(), dictionaryLink)
var annotatedString = message.htmlToAnnotated()
- if (availableDicts.isNotEmpty())
+ // ponytail: in standard flavor, if there are known dicts we show them as downloadable rows instead of bullet links
+ val knownDicts = remember {
+ if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") {
+ getKnownDictionariesForLocale(locale, context)
+ } else emptyList()
+ }
+ if (availableDicts.isNotEmpty() && knownDicts.isEmpty())
annotatedString += AnnotatedString("\n") + availableDicts
- ConfirmationDialog(
- onDismissRequest = onDismissRequest,
- cancelButtonText = stringResource(R.string.dialog_close),
- onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } },
- confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button),
- content = { Text(annotatedString) }
- )
+ if (inline) {
+ ConfirmationDialogContent(
+ onDismissRequest = onDismissRequest,
+ cancelButtonText = stringResource(R.string.dialog_close),
+ onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } },
+ confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button),
+ content = {
+ androidx.compose.foundation.layout.Column {
+ Text(annotatedString)
+ if (knownDicts.isNotEmpty()) {
+ androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ knownDicts.forEach { (desc, link) ->
+ DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {})
+ }
+ }
+ }
+ }
+ )
+ } else {
+ ConfirmationDialog(
+ onDismissRequest = onDismissRequest,
+ cancelButtonText = stringResource(R.string.dialog_close),
+ onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } },
+ confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button),
+ content = {
+ androidx.compose.foundation.layout.Column {
+ Text(annotatedString)
+ if (knownDicts.isNotEmpty()) {
+ androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ knownDicts.forEach { (desc, link) ->
+ DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {})
+ }
+ }
+ }
+ }
+ )
+ }
}
/** if dictionaries for [locale] or language are available returns links to them */
@@ -116,11 +189,182 @@ fun cleanUnusedMainDicts(context: Context) {
for (dir in dirs) {
if (!dir.isDirectory) continue
if (dir.name in usedLocaleLanguageTags) continue
- if (hasAnythingOtherThanExtractedMainDictionary(dir))
+ if (hasAnythingOtherThanExtractedMainDictionary(context, dir))
continue
dir.deleteRecursively()
}
}
-private fun hasAnythingOtherThanExtractedMainDictionary(dir: File) =
- dir.listFiles()?.any { it.name != DictionaryInfoUtils.MAIN_DICT_FILE_NAME } != false
+// ponytail: check if the cached folder contains user-added or downloaded dicts (which shouldn't be automatically deleted or hidden)
+private fun hasAnythingOtherThanExtractedMainDictionary(context: Context, dir: File): Boolean {
+ val files = dir.listFiles() ?: return false
+ if (files.isEmpty()) return false
+ if (files.any { it.name != DictionaryInfoUtils.MAIN_DICT_FILE_NAME }) return true
+ if (files.any { it.name == DictionaryInfoUtils.MAIN_DICT_FILE_NAME }) {
+ val locale = DictionaryInfoUtils.getWordListIdFromFileName(dir.name).constructLocale()
+ val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context) ?: return true
+ val best = LocaleUtils.getBestMatch(locale, assetsList.toList()) {
+ DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(it)
+ }
+ return best == null
+ }
+ return false
+}
+
+// ponytail: Dynamic dictionary downloader using HTTP URL connection.
+fun downloadDictionary(context: Context, locale: Locale, type: String, linkUrl: String, onComplete: (Boolean) -> Unit) {
+ val cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context) ?: return onComplete(false)
+ val targetFile = File(cacheDir, "${type}.dict")
+ CoroutineScope(Dispatchers.IO).launch {
+ var success = false
+ try {
+ java.net.URL(linkUrl).openStream().use { input ->
+ targetFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ success = true
+ } catch (e: Exception) {
+ Log.e("DictionaryUtils", "Failed to download dictionary", e)
+ }
+ withContext(Dispatchers.Main) {
+ onComplete(success)
+ }
+ }
+}
+
+@Composable
+fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefresh: () -> Unit) {
+ val ctx = LocalContext.current
+ val type = remember(link) { link.substringAfterLast("/").substringBefore("_") }
+ val cacheDir = remember(locale) { DictionaryInfoUtils.getCacheDirectoryForLocale(locale, ctx) }
+ val file = remember(cacheDir, type) { cacheDir?.let { File(it, "$type.dict") } }
+ var downloading by remember { mutableStateOf(false) }
+ var exists by remember(file) { mutableStateOf(file?.exists() == true) }
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
+ ) {
+ Text(desc, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
+ if (exists) {
+ var showDeleteDialog by remember { mutableStateOf(false) }
+ androidx.compose.material3.TextButton(onClick = { showDeleteDialog = true }) {
+ Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error)
+ }
+ if (showDeleteDialog) {
+ ConfirmationDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ confirmButtonText = stringResource(R.string.remove),
+ onConfirmed = {
+ file?.delete()
+ exists = false
+ onRefresh()
+ },
+ content = { Text(stringResource(R.string.remove_dictionary_message, type)) }
+ )
+ }
+ } else if (downloading) {
+ Text(
+ stringResource(R.string.downloading),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ } else {
+ androidx.compose.material3.TextButton(onClick = {
+ downloading = true
+ downloadDictionary(ctx, locale, type, link) { success ->
+ downloading = false
+ if (success) {
+ exists = true
+ onRefresh()
+ } else {
+ android.widget.Toast.makeText(ctx, ctx.getString(R.string.download_failed), android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+ }) {
+ Text(stringResource(R.string.download))
+ }
+ }
+ }
+}
+
+// ponytail: check if the main dictionary is missing/not loaded for a given locale
+fun isMainDictionaryMissing(context: Context, locale: Locale): Boolean {
+ // 1. check if there's any dictionary in assets matching the locale
+ val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context)
+ if (assetsList != null) {
+ val best = LocaleUtils.getBestMatch(locale, assetsList.toList()) {
+ DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(it)
+ }
+ if (best != null) return false
+ }
+ // 2. check if cache directory has a main.dict file
+ val cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) }
+ if (cacheDir?.exists() == true && cacheDir.isDirectory) {
+ val hasMain = cacheDir.listFiles()?.any { it.name == "main.dict" } == true
+ if (hasMain) return false
+ }
+ // 3. check if there is a known downloadable main dictionary for this locale
+ val known = getKnownDictionariesForLocale(locale, context)
+ return known.any { (_, link) -> link.substringAfterLast("/").substringBefore("_") == "main" }
+}
+
+// ponytail: helper to host ComposeView in non-Activity window context (e.g. IME Service)
+private class ServiceLifecycleOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner {
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+ private val store = ViewModelStore()
+
+ init {
+ savedStateRegistryController.performRestore(null)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ }
+
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+ override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
+ override val viewModelStore: ViewModelStore get() = store
+
+ fun destroy() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ store.clear()
+ }
+}
+
+// ponytail: bridge compose dialog to legacy view
+fun showMissingDictionaryComposeDialog(context: Context, locale: Locale, windowToken: IBinder, onDismiss: () -> Unit) {
+ val dialog = android.app.Dialog(getPlatformDialogThemeContext(context))
+ val lifecycleOwner = ServiceLifecycleOwner()
+ val composeView = androidx.compose.ui.platform.ComposeView(context).apply {
+ setViewTreeLifecycleOwner(lifecycleOwner)
+ setViewTreeSavedStateRegistryOwner(lifecycleOwner)
+ setViewTreeViewModelStoreOwner(lifecycleOwner)
+ setContent {
+ Theme {
+ MissingDictionaryDialog(
+ onDismissRequest = {
+ dialog.dismiss()
+ onDismiss()
+ },
+ locale = locale,
+ inline = true
+ )
+ }
+ }
+ }
+ dialog.setOnDismissListener {
+ lifecycleOwner.destroy()
+ }
+ dialog.setContentView(composeView)
+ val window = dialog.window
+ val layoutParams = window?.attributes
+ layoutParams?.token = windowToken
+ layoutParams?.type = android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG
+ window?.attributes = layoutParams
+ dialog.show()
+}
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 2d38093cb..54ed101bf 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt
@@ -353,19 +353,26 @@ 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" && BuildConfig.FLAVOR != "offline")
+// ponytail: Split excluded keys into flavor-specific exclusions and main-toolbar-only exclusions to allow clipboard toolbar to render clipboard search and close history.
+private val flavorExcludedKeys by lazy {
+ val customAiKeys = if (BuildConfig.FLAVOR != "standard" && 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, HANDWRITING)
+ listOf(PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING)
else if (BuildConfig.FLAVOR == "offline")
- listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH, HANDWRITING)
+ listOf(HANDWRITING)
else
- listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH)
+ emptyList()
customAiKeys + otherKeys
}
+private val mainToolbarExcludedKeys = listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH)
+
+private val excludedKeys by lazy {
+ flavorExcludedKeys + mainToolbarExcludedKeys
+}
+
val defaultToolbarPref by lazy {
val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) {
"offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE)
@@ -432,7 +439,7 @@ fun getEnabledToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(pref
fun getPinnedToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_PINNED_TOOLBAR_KEYS, defaultPinnedToolbarPref)
-fun getEnabledClipboardToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS, defaultClipboardToolbarPref)
+fun getEnabledClipboardToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS, defaultClipboardToolbarPref, flavorExcludedKeys)
fun addPinnedKey(prefs: SharedPreferences, key: ToolbarKey) {
// remove the existing version of this key and add the enabled one after the last currently enabled key
@@ -455,14 +462,14 @@ fun removePinnedKey(prefs: SharedPreferences, key: ToolbarKey) {
prefs.edit { putString(Settings.PREF_PINNED_TOOLBAR_KEYS, result) }
}
-private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, default: String): List {
+private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, default: String, exclusions: Collection = excludedKeys): List {
val string = prefs.getString(pref, default)!!
return string.split(Separators.ENTRY).mapNotNull {
val split = it.split(Separators.KV)
if (split.last() == "true") {
try {
val key = ToolbarKey.valueOf(split.first())
- if (key in excludedKeys) null else key
+ if (key in exclusions) null else key
} catch (_: IllegalArgumentException) {
null
}
diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt
index 29e98b232..a38dc070b 100644
--- a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt
+++ b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt
@@ -266,7 +266,7 @@ fun WelcomeWizard(
{ step++ },
{ step-- }
) {
- if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") {
+ if (BuildConfig.FLAVOR == "standard") {
val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) }
var currentProvider by remember { mutableStateOf(service.getProvider()) }
val aiConfigured = when (currentProvider) {
diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt
index e8ac238ba..8761f760a 100644
--- a/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt
+++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt
@@ -13,6 +13,31 @@ import helium314.keyboard.settings.previewDark
// taken from StreetComplete
/** Slight specialization of an alert dialog: AlertDialog with OK and Cancel button. Both buttons
* call [onDismissRequest] and the OK button additionally calls [onConfirmed]. */
+@Composable
+fun ConfirmationDialogContent(
+ onDismissRequest: () -> Unit,
+ onConfirmed: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: @Composable (() -> Unit)? = null,
+ content: @Composable (() -> Unit)? = null,
+ confirmButtonText: String = stringResource(android.R.string.ok),
+ cancelButtonText: String = stringResource(android.R.string.cancel),
+ neutralButtonText: String? = null,
+ onNeutral: () -> Unit = { },
+) {
+ ThreeButtonAlertDialogContent(
+ onDismissRequest = onDismissRequest,
+ onConfirmed = onConfirmed,
+ confirmButtonText = confirmButtonText,
+ cancelButtonText = cancelButtonText,
+ neutralButtonText = neutralButtonText,
+ onNeutral = onNeutral,
+ modifier = modifier,
+ title = title,
+ content = content,
+ )
+}
+
@Composable
fun ConfirmationDialog(
onDismissRequest: () -> Unit,
diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
index 1aa928a38..c4219624a 100644
--- a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
+++ b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
@@ -2,6 +2,7 @@
package helium314.keyboard.settings.dialogs
import android.content.Intent
+import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -35,6 +36,7 @@ import helium314.keyboard.latin.common.LocaleUtils.localizedDisplayName
import helium314.keyboard.latin.utils.DictionaryInfoUtils
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.createDictionaryTextAnnotated
+import helium314.keyboard.latin.utils.DownloadableDictionaryRow
import helium314.keyboard.settings.DeleteButton
import helium314.keyboard.settings.ExpandButton
import helium314.keyboard.settings.Theme
@@ -45,6 +47,10 @@ import java.io.File
import java.util.Locale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalResources
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
@Composable
fun DictionaryDialog(
@@ -52,7 +58,8 @@ fun DictionaryDialog(
locale: Locale,
) {
val ctx = LocalContext.current
- val (dictionaries, hasInternal) = getUserAndInternalDictionaries(ctx, locale)
+ var refreshTrigger by remember { mutableStateOf(0) }
+ val (dictionaries, hasInternal) = remember(refreshTrigger) { getUserAndInternalDictionaries(ctx, locale) }
val mainDict = dictionaries.firstOrNull { it.name == Dictionary.TYPE_MAIN + "_" + DictionaryInfoUtils.USER_DICTIONARY_SUFFIX }
val addonDicts = dictionaries.filterNot { it == mainDict }
val picker = dictionaryFilePicker(locale)
@@ -72,7 +79,7 @@ fun DictionaryDialog(
}
}
val internalId = best?.let { "main:" + it.substringAfter("_").substringBefore(".") }
-
+
val color = if (mainDict == null) MaterialTheme.typography.titleSmall.color
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // for disabled look
val bottomPadding = if (mainDict == null) 12.dp else 0.dp
@@ -112,23 +119,41 @@ fun DictionaryDialog(
}
}
if (mainDict != null)
- DictionaryDetails(mainDict)
+ DictionaryDetails(mainDict) { refreshTrigger++ }
if (addonDicts.isNotEmpty()) {
HorizontalDivider()
Text(stringResource(R.string.dictionary_category_title),
modifier = Modifier.padding(vertical = 12.dp),
style = MaterialTheme.typography.titleSmall
)
- addonDicts.forEach { DictionaryDetails(it) }
+ addonDicts.forEach { DictionaryDetails(it) { refreshTrigger++ } }
+ }
+ val knownDicts = remember {
+ if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") {
+ helium314.keyboard.latin.utils.getKnownDictionariesForLocale(locale, ctx)
+ } else emptyList()
}
- val dictString = createDictionaryTextAnnotated(locale)
- if (dictString.isNotEmpty()) {
+ if (knownDicts.isNotEmpty()) {
HorizontalDivider()
Text(stringResource(R.string.dictionary_available),
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
style = MaterialTheme.typography.titleSmall
)
- Text(dictString, style = LocalTextStyle.current.merge(lineHeight = 1.8.em))
+ knownDicts.forEach { (desc, link) ->
+ DownloadableDictionaryRow(locale, desc, link) {
+ refreshTrigger++
+ }
+ }
+ } else {
+ val dictString = createDictionaryTextAnnotated(locale)
+ if (dictString.isNotEmpty()) {
+ HorizontalDivider()
+ Text(stringResource(R.string.dictionary_available),
+ modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(dictString, style = LocalTextStyle.current.merge(lineHeight = 1.8.em))
+ }
}
}
},
@@ -144,7 +169,7 @@ fun DictionaryDialog(
}
@Composable
-private fun DictionaryDetails(dict: File) {
+private fun DictionaryDetails(dict: File, onDelete: () -> Unit) {
val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(dict) ?: return
val type = header.mIdString.substringBefore(":")
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -185,11 +210,15 @@ private fun DictionaryDetails(dict: File) {
ConfirmationDialog(
onDismissRequest = { showDeleteDialog = false },
confirmButtonText = stringResource(R.string.remove),
- onConfirmed = { dict.delete() },
+ onConfirmed = {
+ dict.delete()
+ onDelete()
+ },
content = { Text(stringResource(R.string.remove_dictionary_message, type))}
)
}
+
@Preview
@Composable
private fun Preview() {
diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt
index 22a465166..3d2513796 100644
--- a/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt
+++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt
@@ -32,7 +32,7 @@ import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.previewDark
@Composable
-fun ThreeButtonAlertDialog(
+fun ThreeButtonAlertDialogContent(
onDismissRequest: () -> Unit,
onConfirmed: () -> Unit,
modifier: Modifier = Modifier,
@@ -45,83 +45,114 @@ fun ThreeButtonAlertDialog(
confirmButtonText: String? = stringResource(android.R.string.ok),
cancelButtonText: String = stringResource(android.R.string.cancel),
neutralButtonText: String? = null,
- reducePadding: Boolean = false,
- properties: DialogProperties = DialogProperties()
) {
- Dialog(
- onDismissRequest = onDismissRequest,
- properties = properties
+ Box(
+ modifier = modifier.widthIn(min = 280.dp, max = 560.dp),
+ propagateMinConstraints = true
) {
- Box(
- modifier = modifier.widthIn(min = 280.dp, max = 560.dp),
- propagateMinConstraints = true
+ Surface(
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp,
+ contentColor = contentColorFor(MaterialTheme.colorScheme.surface),
+ border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
) {
- Surface(
- shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp),
- color = MaterialTheme.colorScheme.surface,
- tonalElevation = 6.dp,
- contentColor = contentColorFor(MaterialTheme.colorScheme.surface),
- border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
- ) {
- Column(modifier = Modifier.padding(24.dp)) {
- icon?.let {
- Box(
- Modifier
- .padding(bottom = 16.dp)
- .align(androidx.compose.ui.Alignment.CenterHorizontally)
- ) {
- CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary) {
- icon()
- }
+ Column(modifier = Modifier.padding(24.dp)) {
+ icon?.let {
+ Box(
+ Modifier
+ .padding(bottom = 16.dp)
+ .align(androidx.compose.ui.Alignment.CenterHorizontally)
+ ) {
+ CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary) {
+ icon()
}
}
- title?.let {
- CompositionLocalProvider(
- LocalTextStyle provides MaterialTheme.typography.headlineSmall,
- androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary
- ) {
- Box(Modifier.padding(bottom = 16.dp)) {
- title()
- }
+ }
+ title?.let {
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.headlineSmall,
+ androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary
+ ) {
+ Box(Modifier.padding(bottom = 16.dp)) {
+ title()
}
}
- content?.let {
- CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) {
- if (scrollContent) {
- val scrollState = rememberScrollState()
- Box(Modifier
- .weight(weight = 1f, fill = false)
- .padding(bottom = 24.dp)
- .verticalScroll(scrollState)
- ) {
- content()
- }
- } else {
- Box(Modifier.weight(weight = 1f, fill = false).padding(bottom = 24.dp)) {
- content()
- }
+ }
+ content?.let {
+ CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) {
+ if (scrollContent) {
+ val scrollState = rememberScrollState()
+ Box(Modifier
+ .weight(weight = 1f, fill = false)
+ .padding(bottom = 24.dp)
+ .verticalScroll(scrollState)
+ ) {
+ content()
+ }
+ } else {
+ Box(Modifier.weight(weight = 1f, fill = false).padding(bottom = 24.dp)) {
+ content()
}
}
}
- Row {
- if (neutralButtonText != null)
- TextButton(
- onClick = onNeutral
- ) { Text(neutralButtonText) }
- Spacer(Modifier.weight(1f))
- TextButton(onClick = onDismissRequest) { Text(cancelButtonText) }
- if (confirmButtonText != null)
- TextButton(
- enabled = checkOk(),
- onClick = { onConfirmed(); onDismissRequest() },
- ) { Text(confirmButtonText) }
- }
+ }
+ Row {
+ if (neutralButtonText != null)
+ TextButton(
+ onClick = onNeutral
+ ) { Text(neutralButtonText) }
+ Spacer(Modifier.weight(1f))
+ TextButton(onClick = onDismissRequest) { Text(cancelButtonText) }
+ if (confirmButtonText != null)
+ TextButton(
+ enabled = checkOk(),
+ onClick = { onConfirmed(); onDismissRequest() },
+ ) { Text(confirmButtonText) }
}
}
}
}
}
+@Composable
+fun ThreeButtonAlertDialog(
+ onDismissRequest: () -> Unit,
+ onConfirmed: () -> Unit,
+ modifier: Modifier = Modifier,
+ icon: @Composable (() -> Unit)? = null,
+ title: @Composable (() -> Unit)? = null,
+ content: @Composable (() -> Unit)? = null,
+ scrollContent: Boolean = false,
+ onNeutral: () -> Unit = { },
+ checkOk: () -> Boolean = { true },
+ confirmButtonText: String? = stringResource(android.R.string.ok),
+ cancelButtonText: String = stringResource(android.R.string.cancel),
+ neutralButtonText: String? = null,
+ reducePadding: Boolean = false,
+ properties: DialogProperties = DialogProperties()
+) {
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = properties
+ ) {
+ ThreeButtonAlertDialogContent(
+ onDismissRequest = onDismissRequest,
+ onConfirmed = onConfirmed,
+ modifier = modifier,
+ icon = icon,
+ title = title,
+ content = content,
+ scrollContent = scrollContent,
+ onNeutral = onNeutral,
+ checkOk = checkOk,
+ confirmButtonText = confirmButtonText,
+ cancelButtonText = cancelButtonText,
+ neutralButtonText = neutralButtonText
+ )
+ }
+}
+
@Preview
@Composable
private fun Preview() {
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt
index bc1cae203..e1df6b1e0 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt
@@ -27,7 +27,7 @@ fun AIIntegrationScreen(
return
}
- if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") {
+ if (BuildConfig.FLAVOR == "standard") {
StandardAIIntegrationScreen(onClickBack)
} else {
OfflineAIIntegrationScreen(onClickBack)
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt
index 6f03b8dcc..c5db7023b 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt
@@ -536,7 +536,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull(
)
}
},
- if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) {
+ if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) {
Preference(
name = it.title,
description = it.description,
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
index cdac0851d..1f1a27a77 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
@@ -134,6 +134,23 @@ fun DictionaryScreen(
}
androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp))
+ // Blocked Words Entry
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .padding(vertical = 4.dp, horizontal = 16.dp)
+ .fillMaxWidth()
+ .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) }
+ ) {
+ Text(
+ stringResource(R.string.edit_blocked_words),
+ style = MaterialTheme.typography.titleMedium
+ )
+ NextScreenIcon()
+ }
+ androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp))
+
// Blocklist Entry
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt
index 3c0d9fef8..b55410549 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt
@@ -96,7 +96,7 @@ fun LibrariesHubScreen(
)
// Handwriting Input Plugin
- if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") {
+ if (BuildConfig.FLAVOR == "standard") {
var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) }
LoadHandwritingPluginPreference(
title = stringResource(R.string.libraries_hub_handwriting_title),
@@ -105,7 +105,6 @@ fun LibrariesHubScreen(
onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) }
)
}
-
// Documentation & Features
val uriHandler = LocalUriHandler.current
Preference(
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt
index 7bf751eda..4c1c7669c 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -89,6 +90,10 @@ import helium314.keyboard.settings.initPreview
import helium314.keyboard.settings.layoutFilePicker
import helium314.keyboard.settings.layoutIntent
import helium314.keyboard.settings.previewDark
+import helium314.keyboard.latin.handwriting.HandwritingLoader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import java.util.Locale
@Composable
@@ -232,6 +237,43 @@ fun SubtypeScreen(
}
}
}
+ val recognizer = remember { HandwritingLoader.getRecognizer(ctx) }
+ val languageTag = currentSubtype.locale.toLanguageTag()
+ var isHandwritingDownloaded by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(languageTag) {
+ withContext(Dispatchers.IO) {
+ val ready = recognizer?.isLanguageReady(languageTag) == true
+ withContext(Dispatchers.Main) {
+ isHandwritingDownloaded = ready
+ }
+ }
+ }
+ if (isHandwritingDownloaded) {
+ WithSmallTitle(stringResource(R.string.handwriting)) {
+ ActionRow {
+ Text(
+ text = stringResource(R.string.delete_handwriting_model),
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 10.dp)
+ )
+ DeleteButton {
+ scope.launch(Dispatchers.IO) {
+ val deleted = recognizer?.removeModel(languageTag) == true
+ withContext(Dispatchers.Main) {
+ if (deleted) {
+ isHandwritingDownloaded = false
+ android.widget.Toast.makeText(ctx, ctx.getString(R.string.handwriting_model_deleted), android.widget.Toast.LENGTH_SHORT).show()
+ } else {
+ android.widget.Toast.makeText(ctx, "Failed to delete handwriting model", android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
// Divider removed to match modern MD3 look
Text(
stringResource(R.string.settings_screen_secondary_layouts),
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
index 215331ce8..29c6e31f6 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
@@ -99,8 +99,8 @@ fun createToolbarSettings(context: Context): List {
val filter = { name: String ->
val lowerName = name.lowercase()
when {
- lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline" || BuildConfig.FLAVOR == "standardOptimised"
- lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised"
+ lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "offline"
+ lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard"
lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite"
else -> true
}
diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml
index 86b475c83..7845c628b 100644
--- a/app/src/main/res/layout/handwriting_view.xml
+++ b/app/src/main/res/layout/handwriting_view.xml
@@ -31,6 +31,15 @@
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp" />
+
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 97a09e119..c60a7a7a4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -462,6 +462,8 @@
Handwriting plugin imported successfully
Failed to load handwriting plugin APK
Handwriting
+ Delete downloaded model
+ Handwriting model deleted
Handwriting plugin required
Please load the handwriting plugin library to enable drawing recognition.
Load Plugin
@@ -565,6 +567,13 @@
@android:string/cut
Clipboard
Clear clipboard
+
+ Clipboard search
+
+ Download
+ Downloading…
+ Download failed
+ Installed
Voice input
@string/layout_numpad
Settings
diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
index 33318f2fd..4e5b178e6 100644
--- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
+++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
@@ -41,170 +41,170 @@ class ProofreadService(private val context: Context) {
}
val prefs: SharedPreferences get() = sharedPrefs
-
+
// Singleton holder for model state to prevent reloading on every request
- object ModelHolder {
- var llamaHelper: LlamaHelper? = null
- var currentModelPath: String? = null
- var isModelAvailable: Boolean = true
- var isModelLoaded: Boolean = false
-
- // Smart Unload Logic
- private var unloadJob: Job? = null
- private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
- private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes
- private val loadMutex = Mutex()
-
- // Flow for LLM events
- val llmFlow = MutableSharedFlow(
- extraBufferCapacity = 64,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
-
- @Synchronized
- fun scheduleUnload(context: Context) {
- unloadJob?.cancel()
-
- val prefs = context.prefs()
- val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED)
-
- if (keepLoaded) {
- Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)")
- return
- }
+object ModelHolder {
+ var llamaHelper: LlamaHelper? = null
+ var currentModelPath: String? = null
+ var isModelAvailable: Boolean = true
+ var isModelLoaded: Boolean = false
+
+ // Smart Unload Logic
+ private var unloadJob: Job? = null
+ private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
+ private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes
+ private val loadMutex = Mutex()
+
+ // Flow for LLM events
+ val llmFlow = MutableSharedFlow(
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ @Synchronized
+ fun scheduleUnload(context: Context) {
+ unloadJob?.cancel()
+
+ val prefs = context.prefs()
+ val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED)
+
+ if (keepLoaded) {
+ Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)")
+ return
+ }
- unloadJob = scope.launch {
- delay(UNLOAD_DELAY_MS)
- unloadModel()
- Log.i(TAG, "Offline AI model unloaded due to inactivity")
- }
+ unloadJob = scope.launch {
+ delay(UNLOAD_DELAY_MS)
+ unloadModel()
+ Log.i(TAG, "Offline AI model unloaded due to inactivity")
}
+ }
+
+ @Synchronized
+ fun cancelUnload() {
+ unloadJob?.cancel()
+ unloadJob = null
+ }
- @Synchronized
- fun cancelUnload() {
- unloadJob?.cancel()
- unloadJob = null
+ @Synchronized
+ fun unloadModel() {
+ try {
+ llamaHelper?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error unloading llama model", e)
}
+ llamaHelper = null
+ currentModelPath = null
+ isModelLoaded = false
+ isModelAvailable = true
+ }
- @Synchronized
- fun unloadModel() {
- try {
- llamaHelper?.release()
- } catch (e: Exception) {
- Log.w(TAG, "Error unloading llama model", e)
- }
- llamaHelper = null
- currentModelPath = null
- isModelLoaded = false
- isModelAvailable = true
+ suspend fun loadModel(
+ context: Context,
+ modelPath: String
+ ): Boolean = loadMutex.withLock {
+ cancelUnload()
+
+ // Check if already loaded with same path
+ if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) {
+ return true
}
- suspend fun loadModel(
- context: Context,
- modelPath: String
- ): Boolean = loadMutex.withLock {
- cancelUnload()
+ unloadModel() // Ensure clean slate if path changed
- // Check if already loaded with same path
- if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) {
- return true
- }
+ return try {
+ val contentResolver = context.contentResolver
+ val helper = LlamaHelper(
+ contentResolver,
+ scope,
+ llmFlow
+ )
- unloadModel() // Ensure clean slate if path changed
-
- return try {
- val contentResolver = context.contentResolver
- val helper = LlamaHelper(
- contentResolver,
- scope,
- llmFlow
- )
-
- // Get llama via reflection
- val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true }
- val llamaLazy = llamaField.get(helper) as Lazy
- val llama = llamaLazy.value
-
- // Detach model file descriptor
- val uri = android.net.Uri.parse(modelPath)
- val pfd = contentResolver.openFileDescriptor(uri, "r")
- ?: throw IllegalArgumentException("Failed to open model file descriptor")
- val modelFd = pfd.detachFd()
-
- // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs)
- val cores = Runtime.getRuntime().availableProcessors()
- val threads = if (cores <= 4) cores else 4
-
- Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false")
-
- // Construct parameters map
- val params = mutableMapOf(
- "model" to modelPath,
- "model_fd" to modelFd,
- "use_mmap" to false,
- "use_mlock" to false,
- "n_ctx" to 2048,
- "embedding" to false,
- "n_batch" to 512,
- "n_threads" to threads,
- "n_gpu_layers" to 0,
- "vocab_only" to false,
- "lora" to "",
- "lora_scaled" to 1.0,
- "rope_freq_base" to 0.0,
- "rope_freq_scale" to 0.0
- )
-
- // JNI callback called by native code for each token
- val callback: (String) -> Unit = { word ->
- try {
- val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true }
- val currentAllText = allTextField.get(helper) as String
- allTextField.set(helper, currentAllText + word)
-
- val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true }
- val currentCount = tokenCountField.get(helper) as Int
- tokenCountField.set(helper, currentCount + 1)
-
- helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1))
- } catch (e: Throwable) {
- Log.e(TAG, "Error in native token callback", e)
- }
- }
+ // Get llama via reflection
+ val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true }
+ val llamaLazy = llamaField.get(helper) as Lazy
+ val llama = llamaLazy.value
- // Start the engine
- val result = llama.startEngine(params, callback)
+ // Detach model file descriptor
+ val uri = android.net.Uri.parse(modelPath)
+ val pfd = contentResolver.openFileDescriptor(uri, "r")
+ ?: throw IllegalArgumentException("Failed to open model file descriptor")
+ val modelFd = pfd.detachFd()
- val contextId = result?.get("contextId") as? Int
- ?: throw IllegalStateException("contextId not found in result map")
+ // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs)
+ val cores = Runtime.getRuntime().availableProcessors()
+ val threads = if (cores <= 4) cores else 4
- // Set currentContext via reflection
- val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true }
- currentContextField.set(helper, contextId)
+ Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false")
- // Emit Loaded event
- helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath))
+ // Construct parameters map
+ val params = mutableMapOf(
+ "model" to modelPath,
+ "model_fd" to modelFd,
+ "use_mmap" to false,
+ "use_mlock" to false,
+ "n_ctx" to 2048,
+ "embedding" to false,
+ "n_batch" to 512,
+ "n_threads" to threads,
+ "n_gpu_layers" to 0,
+ "vocab_only" to false,
+ "lora" to "",
+ "lora_scaled" to 1.0,
+ "rope_freq_base" to 0.0,
+ "rope_freq_scale" to 0.0
+ )
- llamaHelper = helper
- currentModelPath = modelPath
- isModelLoaded = true
- isModelAvailable = true
- true
- } catch (e: Throwable) {
- Log.e(TAG, "Failed to load GGUF model", e)
- isModelAvailable = false
- false
+ // JNI callback called by native code for each token
+ val callback: (String) -> Unit = { word ->
+ try {
+ val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true }
+ val currentAllText = allTextField.get(helper) as String
+ allTextField.set(helper, currentAllText + word)
+
+ val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true }
+ val currentCount = tokenCountField.get(helper) as Int
+ tokenCountField.set(helper, currentCount + 1)
+
+ helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1))
+ } catch (e: Throwable) {
+ Log.e(TAG, "Error in native token callback", e)
+ }
}
- }
- private const val TAG = "LlamaProofreadService"
+ // Start the engine
+ val result = llama.startEngine(params, callback)
+
+ val contextId = result?.get("contextId") as? Int
+ ?: throw IllegalStateException("contextId not found in result map")
+
+ // Set currentContext via reflection
+ val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true }
+ currentContextField.set(helper, contextId)
+
+ // Emit Loaded event
+ helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath))
+
+ llamaHelper = helper
+ currentModelPath = modelPath
+ isModelLoaded = true
+ isModelAvailable = true
+ true
+ } catch (e: Throwable) {
+ Log.e(TAG, "Failed to load GGUF model", e)
+ isModelAvailable = false
+ false
+ }
}
+ private const val TAG = "LlamaProofreadService"
+}
+
// AI Provider support (API compatibility)
enum class AIProvider {
GEMINI, GROQ, OPENAI
}
-
+
fun getProvider(): AIProvider = AIProvider.GROQ
fun setProvider(provider: AIProvider) { /* No-op */ }
@@ -214,7 +214,7 @@ class ProofreadService(private val context: Context) {
fun getApiKey(): String? = null
fun setApiKey(apiKey: String?) { /* No-op */ }
fun hasApiKey(): Boolean = false
-
+
// HuggingFace stubs
fun getHuggingFaceToken(): String? = null
fun setHuggingFaceToken(token: String?) { /* No-op */ }
@@ -231,7 +231,7 @@ class ProofreadService(private val context: Context) {
// Model management - single model path (no encoder/decoder split)
fun getModelPath(): String? = sharedPrefs.getString(KEY_MODEL_PATH, null)
-
+
fun setModelPath(path: String?) {
sharedPrefs.edit().apply {
if (path.isNullOrBlank()) {
@@ -246,7 +246,7 @@ class ProofreadService(private val context: Context) {
// Decoder path (kept for API compatibility, not used with llamacpp)
fun getDecoderPath(): String? = sharedPrefs.getString(KEY_DECODER_PATH, null)
-
+
fun setDecoderPath(path: String?) {
sharedPrefs.edit().apply {
if (path.isNullOrBlank()) {
@@ -260,7 +260,7 @@ class ProofreadService(private val context: Context) {
// Tokenizer path (not needed with GGUF - tokenizer is embedded)
fun getTokenizerPath(): String? = sharedPrefs.getString(KEY_TOKENIZER_PATH, null)
-
+
fun setTokenizerPath(path: String?) {
sharedPrefs.edit().apply {
if (path.isNullOrBlank()) {
@@ -287,7 +287,7 @@ class ProofreadService(private val context: Context) {
fun getModelName(): String {
val path = getModelPath()
if (path.isNullOrBlank()) return "No Model Selected"
-
+
if (path.startsWith("content://")) {
try {
val uri = Uri.parse(path)
@@ -303,12 +303,12 @@ class ProofreadService(private val context: Context) {
Log.w(TAG, "Failed to resolve content URI name", e)
}
}
-
+
return File(path).name.takeIf { it.isNotEmpty() } ?: "Local Model"
}
fun setModelName(name: String) { /* No-op */ }
-
+
fun getTargetLanguage(): String = "English"
fun setTargetLanguage(language: String) { /* No-op */ }
@@ -365,7 +365,7 @@ class ProofreadService(private val context: Context) {
val topK = sharedPrefs.getInt(Settings.PREF_OFFLINE_TOP_K, Defaults.PREF_OFFLINE_TOP_K)
val minP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_MIN_P, Defaults.PREF_OFFLINE_MIN_P)
val showThinkingVal = showThinking ?: sharedPrefs.getBoolean(Settings.PREF_OFFLINE_SHOW_THINKING, Defaults.PREF_OFFLINE_SHOW_THINKING)
-
+
// Build the prompt
val systemPrompt = overridePrompt ?: getSystemPrompt()
val fullPrompt = if (systemPrompt.contains("{text}")) {
@@ -394,7 +394,7 @@ class ProofreadService(private val context: Context) {
"Input: $text\n" +
"Output:"
}
-
+
// Collect generated text from the flow
val generatedText = StringBuilder()
val helper = ModelHolder.llamaHelper
@@ -411,7 +411,7 @@ class ProofreadService(private val context: Context) {
maxTokens = maxTokens,
showThinking = showThinkingVal
)
-
+
// Collect events until done
ModelHolder.llmFlow.takeWhile { event ->
when (event) {
@@ -444,7 +444,7 @@ class ProofreadService(private val context: Context) {
cleanedOutput = cleanedOutput.substring(text.length).trim()
}
}
-
+
// Truncate at the first occurrence of subsequent template markers
val markers = listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:", "Input:", "Instruction:", "Output:", "Corrected:")
for (marker in markers) {
@@ -455,14 +455,14 @@ class ProofreadService(private val context: Context) {
}
}
}
-
+
// Also truncate at any newline followed by a potential template header (e.g., "\nDraft email:", "\nCorrection:")
val headerRegex = Regex("\\n[a-zA-Z0-9 ]+:")
val match = headerRegex.find(cleanedOutput)
if (match != null) {
cleanedOutput = cleanedOutput.substring(0, match.range.first).trim()
}
-
+
// Also strip common prefixes that the model might generate or echo
val prefixesToStrip = listOf(
"Output:", "Corrected:", "Translation:", "Response:", "Result:",
@@ -474,7 +474,7 @@ class ProofreadService(private val context: Context) {
break
}
}
-
+
// If the model wrapped the output in quotes, strip them
if (cleanedOutput.startsWith("\"") && cleanedOutput.endsWith("\"")) {
cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim()
@@ -482,7 +482,7 @@ class ProofreadService(private val context: Context) {
if (cleanedOutput.startsWith("'") && cleanedOutput.endsWith("'")) {
cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim()
}
-
+
// Post-process to strip thinking/reasoning tags if showThinkingVal is false
val finalOutput = if (!showThinkingVal) {
stripThinkingTags(cleanedOutput)
diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt
index 49cb8584c..062d2dc46 100644
--- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt
+++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt
@@ -17,6 +17,8 @@ class ProofreadService(private val context: Context) {
GEMINI, GROQ, OPENAI
}
+
+
val prefs: SharedPreferences get() = context.prefs()
// Always returns GEMINI as default, but methods do nothing
diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt
index 5230a545f..010ba170f 100644
--- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt
+++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt
@@ -63,12 +63,12 @@ class ProofreadService(private val context: Context) {
// Provider selection
fun getProvider(): AIProvider {
- val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name)
- return try {
- AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name)
- } catch (e: IllegalArgumentException) {
- AIProvider.GEMINI
- }
+val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name)
+return try {
+ AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name)
+} catch (e: IllegalArgumentException) {
+ AIProvider.GEMINI
+}
}
fun setProvider(provider: AIProvider) {
diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt
index a792af63b..e50e47d95 100644
--- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt
+++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt
@@ -287,6 +287,29 @@ f""", // no newline at the end
}]]""", Expected('.'.code, ".", popups = listOf(">").map { it to it.first().code }))
}
+ @Test fun numberRowKeepsDigitsWhenShifted() {
+ val numberRowKey = """[[{ "label": "1", "popup": {
+ "relevant": [
+ { "label": "!" },
+ { "label": "¹" },
+ { "label": "½" },
+ { "label": "⅓" },
+ { "label": "¼" },
+ { "label": "⅛" }
+ ]
+ } }]]"""
+ val expected = Expected('1'.code, "1", popups = listOf("!", "¹", "½", "⅓", "¼", "⅛").map { it to it.first().code })
+ listOf(
+ KeyboardId.ELEMENT_ALPHABET,
+ KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED,
+ KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED,
+ KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED
+ ).forEach { elementId ->
+ params.mId = KeyboardLayoutSet.getFakeKeyboardId(elementId)
+ assertIsExpected(numberRowKey, expected)
+ }
+ }
+
@Test fun nestedSelectors() {
assertIsExpected("""[[{ "$": "shift_state_selector",
"shiftedManual": { "code": 34, "label": "\"", "popup": {
diff --git a/docs/badges/download.svg b/docs/badges/download.svg
deleted file mode 100644
index ee156f654..000000000
--- a/docs/badges/download.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg
deleted file mode 100644
index df1200edc..000000000
--- a/docs/badges/downloads.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg
deleted file mode 100644
index 66a371b67..000000000
--- a/docs/badges/stars.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt
index 475e3ca5d..da4da604d 100644
--- a/fastlane/metadata/android/en-US/changelogs/3860.txt
+++ b/fastlane/metadata/android/en-US/changelogs/3860.txt
@@ -1,3 +1,3 @@
- Two-thumb: down-swipe shortcut popup now aligns to the letter row (swipe down on a key selects the icon above it)
- Flag learned/typed words that aren't in a dictionary; long-press to Add or Block them, plus a new Blocklist settings screen
-- Fix two-thumb ghost-merge: a deleted or cancelled gesture trail no longer fuses into the next swipe
+- Fix two-thumb ghost-merge: a deleted or cancelled gesture trail no longer fuses into the next swipe
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/3870.txt b/fastlane/metadata/android/en-US/changelogs/3870.txt
new file mode 100644
index 000000000..69306f369
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3870.txt
@@ -0,0 +1,3 @@
+- Fixed offline model
+- Fixed handwriting plugin crash
+- Added handwriting model download progress
diff --git a/fastlane/metadata/android/en-US/changelogs/3880.txt b/fastlane/metadata/android/en-US/changelogs/3880.txt
new file mode 100644
index 000000000..1efbc9aac
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3880.txt
@@ -0,0 +1,13 @@
+- Exclude non-en-US dictionaries from standard flavor assets to reduce app size
+- Add dynamic dictionary downloader and uninstaller for standard flavor
+- Add download button on keyboard toolbar when layout dictionary is missing
+- Allow deleting downloaded handwriting models in settings
+- Tune double-tap shift timing and improve stability of handwriting gestures
+- Restore close/search icons on clipboard toolbar
+- Fix settings displaying disabled additional subtypes in dictionaries list
+- Keep number row digits when keyboard is shifted to uppercase
+- Fix WindowManager$BadTokenException crash on IME overlay dialogs
+- Fix recently used emojis getting stuck on split toolbar
+- Close emoji search automatically when dictionary download completes
+- Show emoji dictionary download button on split toolbar when missing
+- Show handwriting plugin download button on canvas when missing