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 - VersionVersionv${VERSION}v${VERSION} - EOF - - # Downloads count badge - cat > docs/badges/downloads.svg << EOF - DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} - EOF - - # Stars badge - cat > docs/badges/stars.svg << EOF - StarsStars${STARS_FMT}${STARS_FMT} - EOF - - echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" - - - name: Update README badge URLs - run: | - # Replace shields.io URLs with local badge paths - sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md - sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md - sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md - - - name: Commit changes - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add docs/badges/ README.md - git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" - git push diff --git a/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 @@ -VersionVersionv3.8.5v3.8.5 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 @@ -DownloadsDownloads3069330693 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 @@ -StarsStars488488 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