diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml new file mode 100644 index 000000000..98b5de241 --- /dev/null +++ b/.github/workflows/build-release-apk.yml @@ -0,0 +1,64 @@ +name: Build Release APKs + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Decode Keystore + env: + RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }} + if: ${{ env.RELEASE_KEYSTORE != '' }} + run: | + echo "${{ secrets.RELEASE_KEYSTORE }}" | base64 -d > leantype-release.jks + echo "keyAlias=${{ secrets.RELEASE_KEY_ALIAS }}" > keystore.properties + echo "keyPassword=${{ secrets.RELEASE_KEY_PASSWORD }}" >> keystore.properties + echo "storeFile=leantype-release.jks" >> keystore.properties + echo "storePassword=${{ secrets.RELEASE_STORE_PASSWORD }}" >> keystore.properties + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Release APKs + run: ./gradlew assembleStandardRelease assembleStandardfullRelease assembleOfflineRelease assembleOfflineliteRelease + + - name: Generate Release Notes + # ponytail: generate release notes from changelog during build + run: python3 docs/scripts/generate_release_notes.py + + - name: Upload APKs and Release Notes + uses: actions/upload-artifact@v4 + with: + name: LeanType-Release-APKs + path: | + app/build/outputs/apk/**/*.apk + docs/releasenote/release_notes_v*.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/v') + with: + body_path: docs/releasenote/release_notes_temp.md + files: | + app/build/outputs/apk/standard/release/*.apk + app/build/outputs/apk/standardfull/release/*.apk + app/build/outputs/apk/offline/release/*.apk + app/build/outputs/apk/offlinelite/release/*.apk diff --git a/.gitignore b/.gitignore index 9e18d66b9..4fbd971d2 100755 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ docs/superpowers/ # AI agent config (personal, not shared) .pi/ + +# Temporary generated files +docs/releasenote/release_notes_temp.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2823bd71e..84eecc658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) shown. (Upstream bug LeanBitLab/LeanType#186; upstream PR #194.) ### Upstream -- 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, +- Merged **LeanBitLab/LeanType v3.8.9** (from v3.8.3, including v3.8.7/v3.8.8 and one post-tag docs/badge + commit) — the source of the handwriting, llama.cpp/GGUF, dynamic downloader, text-editing mode, 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. diff --git a/README.md b/README.md index 095bd0754..828457305 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **🪟 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. +- **✍️ Text editing mode** - A toolbar key opens a text-editing overlay for selection, cursor movement, and clipboard actions. - **🎨 Modern UI** - "Squircle" key backgrounds, refined icons, and polished aesthetics. - **🔄 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f16260ef6..39f3bf9eb 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,7 +28,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") ndk { - abiFilters.addAll(arrayOf("arm64-v8a")) + abiFilters.addAll(arrayOf("armeabi-v7a", "arm64-v8a")) } } @@ -38,6 +38,10 @@ android { dimension = "privacy" minSdk = 23 } + create("standardfull") { + dimension = "privacy" + minSdk = 23 + } create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" @@ -102,6 +106,7 @@ android { val flavor = productFlavors.firstOrNull()?.name ?: "" val number = when(flavor) { "standard" -> "1" + "standardfull" -> "1" "offline" -> "2" "offlinelite" -> "3" else -> "" @@ -124,7 +129,7 @@ android { variant.proguardFiles.add(project.layout.buildDirectory.file(getDefaultProguardFile("proguard-android.txt").absolutePath)) variant.proguardFiles.add(project.layout.buildDirectory.file(project.buildFile.parent + "/proguard-rules.pro")) } - if (variant.flavorName == "standard") { + if (variant.flavorName == "standard" || variant.flavorName == "standardfull") { // 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) { @@ -206,6 +211,12 @@ android { // these orphaned strings are harmlessly stripped by R8 during minification. disable += "ExtraTranslation" } + + sourceSets { + getByName("standardfull") { + java.srcDirs("src/standard/java") + } + } } dependencies { @@ -234,6 +245,8 @@ 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 + "standardfullImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") + "standardfullImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") @@ -249,7 +262,7 @@ dependencies { // ML Kit Digital Ink Recognition — required by the handwriting plugin. // ML Kit's internal asset manager and native library loader use the host app context, // so the host app must compile and include the client library resources/libraries. - "standardImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") + "standardfullImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") // test testImplementation(kotlin("test")) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c32d5edf0..59b51eedb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -139,6 +139,19 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only tools:node="remove" /> + + + + + diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index d347b04c0..d6134485a 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -149,6 +149,9 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp KeyCode.TOGGLE_TOUCHPAD_MODE -> { PointerTracker.sPersistentTouchpadModeActive = !PointerTracker.sPersistentTouchpadModeActive if (PointerTracker.sPersistentTouchpadModeActive) { + sPersistentTextEditModeActive = false + keyboardSwitcher.hideTextEditView() + val touchpadView = keyboardSwitcher.touchpadView if (touchpadView != null) { setupTouchpadListener(touchpadView) @@ -159,6 +162,29 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } return } + KeyCode.TOGGLE_TEXT_EDIT_MODE -> { + sPersistentTextEditModeActive = !sPersistentTextEditModeActive + if (sPersistentTextEditModeActive) { + PointerTracker.sPersistentTouchpadModeActive = false + keyboardSwitcher.hideTouchpadView() + + val textEditView = keyboardSwitcher.textEditView + if (textEditView != null) { + setupTextEditListener(textEditView) + keyboardSwitcher.showTextEditView() + } + } else { + keyboardSwitcher.hideTextEditView() + } + return + } + KeyCode.FORWARD_DELETE -> { + val connection = inputLogic.connection + val eventTime = android.os.SystemClock.uptimeMillis() + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_FORWARD_DEL, 0, 0)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_FORWARD_DEL, 0, 0)) + return + } } val mkv = keyboardSwitcher.mainKeyboardView @@ -637,10 +663,50 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onThreeFingerSwipeDown() { onCodeInput(KeyCode.REDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } + override fun onClose() { + PointerTracker.sPersistentTouchpadModeActive = false + keyboardSwitcher.hideTouchpadView() + } + }) + } + + fun setupTextEditListener(textEditView: TextEditView) { + textEditView.setTextEditListener(object : TextEditView.TextEditListener { + override fun onCursorMove(keyCode: Int, isSelecting: Boolean) { + if (isSelecting) { + val androidKeyCode = when (keyCode) { + KeyCode.ARROW_UP -> KeyEvent.KEYCODE_DPAD_UP + KeyCode.ARROW_DOWN -> KeyEvent.KEYCODE_DPAD_DOWN + KeyCode.ARROW_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT + KeyCode.ARROW_RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT + else -> 0 + } + if (androidKeyCode != 0) { + val eventTime = android.os.SystemClock.uptimeMillis() + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, androidKeyCode, 0, KeyEvent.META_SHIFT_ON)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, androidKeyCode, 0, KeyEvent.META_SHIFT_ON)) + connection.sendKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0)) + } + } else { + onCodeInput(keyCode, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + + override fun onCodeInput(keyCode: Int) { + onCodeInput(keyCode, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + + override fun onClose() { + sPersistentTextEditModeActive = false + keyboardSwitcher.hideTextEditView() + } }) } companion object { + @JvmField + var sPersistentTextEditModeActive = false private enum class MetaPressState { UNSET, // default state, not active SET, // enabled without onPressKey (e.g. in popup) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index a5490480d..bdb2b7f08 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -72,6 +72,7 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private ClipboardHistoryView mClipboardHistoryView; private HandwritingView mHandwritingView; private TouchpadView mTouchpadView; + private TextEditView mTextEditView; private TextView mFakeToastView; private LatinIME mLatinIME; private RichInputMethodManager mRichImm; @@ -343,7 +344,7 @@ private void setMainKeyboardFrame( final int stripVisibility = settingsValues.mToolbarMode == ToolbarMode.HIDDEN ? View.GONE : View.VISIBLE; mStripContainer.setVisibility(stripVisibility); PointerTracker.switchTo(mKeyboardView); - if (PointerTracker.sPersistentTouchpadModeActive) { + if (PointerTracker.sPersistentTouchpadModeActive || KeyboardActionListenerImpl.sPersistentTextEditModeActive) { mKeyboardView.setVisibility(visibility == View.VISIBLE ? View.INVISIBLE : View.GONE); } else { mKeyboardView.setVisibility(visibility); @@ -382,6 +383,21 @@ private void setMainKeyboardFrame( } else { if (mTouchpadView != null) mTouchpadView.setVisibility(View.GONE); } + + if (KeyboardActionListenerImpl.sPersistentTextEditModeActive) { + if (mTextEditView != null) { + mTextEditView.setVisibility(visibility); + mTextEditView.applyColors(Settings.getValues().mColors); + mTextEditView.setPadding( + mKeyboardView.getPaddingLeft(), + mKeyboardView.getPaddingTop(), + mKeyboardView.getPaddingRight(), + mKeyboardView.getPaddingBottom() + ); + } + } else { + if (mTextEditView != null) mTextEditView.setVisibility(View.GONE); + } } // Implements {@link KeyboardState.SwitchActions}. @@ -394,6 +410,10 @@ public void setEmojiKeyboard() { if (mTouchpadView != null) { mTouchpadView.setVisibility(View.GONE); } + KeyboardActionListenerImpl.sPersistentTextEditModeActive = false; + if (mTextEditView != null) { + mTextEditView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -422,6 +442,10 @@ public void setClipboardKeyboard() { if (mTouchpadView != null) { mTouchpadView.setVisibility(View.GONE); } + KeyboardActionListenerImpl.sPersistentTextEditModeActive = false; + if (mTextEditView != null) { + mTextEditView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -449,6 +473,10 @@ public void setHandwritingKeyboard() { if (mTouchpadView != null) { mTouchpadView.setVisibility(View.GONE); } + KeyboardActionListenerImpl.sPersistentTextEditModeActive = false; + if (mTextEditView != null) { + mTextEditView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); mKeyboardView.setVisibility(View.GONE); mEmojiTabStripView.setVisibility(View.GONE); @@ -632,6 +660,9 @@ public void showTouchpadView() { mKeyboardViewWrapper.findViewById(R.id.btn_stop_one_handed_mode).setVisibility(View.GONE); mKeyboardViewWrapper.findViewById(R.id.btn_switch_one_handed_mode).setVisibility(View.GONE); mKeyboardViewWrapper.findViewById(R.id.btn_resize_one_handed_mode).setVisibility(View.GONE); + if (Settings.getValues().mTouchpadFullscreen) { + mStripContainer.setVisibility(View.GONE); + } // Apply bottom padding to avoid overlapping the navigation bar mTouchpadView.setPadding( mKeyboardView.getPaddingLeft(), @@ -649,6 +680,7 @@ public void hideTouchpadView() { mTouchpadView.setVisibility(View.GONE); mKeyboardView.setVisibility(View.VISIBLE); mKeyboardView.setAlpha(1.0f); + mStripContainer.setVisibility(Settings.getValues().mToolbarMode == ToolbarMode.HIDDEN ? View.GONE : View.VISIBLE); // Restore one-handed buttons if needed if (mKeyboardViewWrapper.getOneHandedModeEnabled()) { mKeyboardViewWrapper.findViewById(R.id.btn_stop_one_handed_mode).setVisibility(View.VISIBLE); @@ -661,6 +693,41 @@ public TouchpadView getTouchpadView() { return mTouchpadView; } + public void showTextEditView() { + if (mTextEditView == null) return; + mKeyboardView.setVisibility(View.INVISIBLE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + mKeyboardViewWrapper.findViewById(R.id.btn_stop_one_handed_mode).setVisibility(View.GONE); + mKeyboardViewWrapper.findViewById(R.id.btn_switch_one_handed_mode).setVisibility(View.GONE); + mKeyboardViewWrapper.findViewById(R.id.btn_resize_one_handed_mode).setVisibility(View.GONE); + mTextEditView.setPadding( + mKeyboardView.getPaddingLeft(), + mKeyboardView.getPaddingTop(), + mKeyboardView.getPaddingRight(), + mKeyboardView.getPaddingBottom() + ); + mTextEditView.applyColors(Settings.getValues().mColors); + mTextEditView.setVisibility(View.VISIBLE); + mMainKeyboardFrame.setVisibility(View.VISIBLE); + } + + public void hideTextEditView() { + if (mTextEditView == null) return; + mTextEditView.setVisibility(View.GONE); + mKeyboardView.setVisibility(View.VISIBLE); + mKeyboardView.setAlpha(1.0f); + if (mKeyboardViewWrapper.getOneHandedModeEnabled()) { + mKeyboardViewWrapper.findViewById(R.id.btn_stop_one_handed_mode).setVisibility(View.VISIBLE); + mKeyboardViewWrapper.findViewById(R.id.btn_switch_one_handed_mode).setVisibility(View.VISIBLE); + mKeyboardViewWrapper.findViewById(R.id.btn_resize_one_handed_mode).setVisibility(View.VISIBLE); + } + } + + public TextEditView getTextEditView() { + return mTextEditView; + } + public void toggleSplitKeyboardMode() { final Settings settings = Settings.getInstance(); settings.writeSplitKeyboardEnabled( @@ -921,6 +988,13 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH } } + mTextEditView = mCurrentInputView.findViewById(R.id.text_edit_view); + if (KeyboardActionListenerImpl.sPersistentTextEditModeActive && mTextEditView != null) { + if (mLatinIME.mKeyboardActionListener instanceof KeyboardActionListenerImpl) { + ((KeyboardActionListenerImpl) mLatinIME.mKeyboardActionListener).setupTextEditListener(mTextEditView); + } + } + mKeyboardView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { if (mTouchpadView != null && mTouchpadView.getVisibility() == View.VISIBLE) { mTouchpadView.setPadding( @@ -930,6 +1004,14 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mKeyboardView.getPaddingBottom() ); } + if (mTextEditView != null && mTextEditView.getVisibility() == View.VISIBLE) { + mTextEditView.setPadding( + mKeyboardView.getPaddingLeft(), + mKeyboardView.getPaddingTop(), + mKeyboardView.getPaddingRight(), + mKeyboardView.getPaddingBottom() + ); + } }); return mCurrentInputView; diff --git a/app/src/main/java/helium314/keyboard/keyboard/TextEditView.java b/app/src/main/java/helium314/keyboard/keyboard/TextEditView.java new file mode 100644 index 000000000..7005f41a4 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/TextEditView.java @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.keyboard; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import helium314.keyboard.keyboard.internal.KeyboardIconsSet; +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; +import helium314.keyboard.latin.R; +import helium314.keyboard.latin.common.ColorType; +import helium314.keyboard.latin.common.Colors; +import helium314.keyboard.latin.settings.Settings; + +public class TextEditView extends LinearLayout { + + public interface TextEditListener { + void onCursorMove(int keyCode, boolean isSelecting); + void onCodeInput(int keyCode); + void onClose(); + } + + private TextEditListener mListener; + private boolean mSelectionMode = false; + + // Buttons + private TextView mBtnSelectAll; + private TextView mBtnSelect; + private TextView mBtnCut; + private TextView mBtnCopy; + private TextView mBtnPaste; + private ImageView mBtnClose; + + private ImageView mBtnHome; + private ImageView mBtnWordLeft; + private ImageView mBtnArrowUp; + private ImageView mBtnWordRight; + private ImageView mBtnEnd; + + private ImageView mBtnBackspace; + private ImageView mBtnArrowLeft; + private ImageView mBtnArrowDown; + private ImageView mBtnArrowRight; + private ImageView mBtnDelete; + + public TextEditView(Context context) { + super(context); + init(context); + } + + public TextEditView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public TextEditView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(Context context) { + setOrientation(VERTICAL); + setClickable(true); + setFocusable(true); + setFitsSystemWindows(true); + + LayoutInflater.from(context).inflate(R.layout.text_edit_view, this, true); + + mBtnSelectAll = findViewById(R.id.btn_select_all); + mBtnSelect = findViewById(R.id.btn_select); + mBtnCut = findViewById(R.id.btn_cut); + mBtnCopy = findViewById(R.id.btn_copy); + mBtnPaste = findViewById(R.id.btn_paste); + mBtnClose = findViewById(R.id.btn_close); + + mBtnHome = findViewById(R.id.btn_home); + mBtnWordLeft = findViewById(R.id.btn_word_left); + mBtnArrowUp = findViewById(R.id.btn_arrow_up); + mBtnWordRight = findViewById(R.id.btn_word_right); + mBtnEnd = findViewById(R.id.btn_end); + + mBtnBackspace = findViewById(R.id.btn_backspace); + mBtnArrowLeft = findViewById(R.id.btn_arrow_left); + mBtnArrowDown = findViewById(R.id.btn_arrow_down); + mBtnArrowRight = findViewById(R.id.btn_arrow_right); + mBtnDelete = findViewById(R.id.btn_delete); + + setupClickListeners(); + } + + private void setupClickListeners() { + mBtnSelectAll.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL); + }); + + mBtnSelect.setOnClickListener(v -> { + mSelectionMode = !mSelectionMode; + applyColors(Settings.getValues().mColors); + }); + + mBtnCut.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.CLIPBOARD_CUT); + mSelectionMode = false; + applyColors(Settings.getValues().mColors); + }); + + mBtnCopy.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.CLIPBOARD_COPY); + mSelectionMode = false; + applyColors(Settings.getValues().mColors); + }); + + mBtnPaste.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.CLIPBOARD_PASTE); + }); + + mBtnClose.setOnClickListener(v -> { + if (mListener != null) mListener.onClose(); + }); + + mBtnHome.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.MOVE_START_OF_LINE); + }); + + mBtnWordLeft.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.WORD_LEFT); + }); + + mBtnArrowUp.setOnClickListener(v -> { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_UP, mSelectionMode); + }); + + mBtnWordRight.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.WORD_RIGHT); + }); + + mBtnEnd.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.MOVE_END_OF_LINE); + }); + + mBtnBackspace.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.DELETE); + }); + + mBtnArrowLeft.setOnClickListener(v -> { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_LEFT, mSelectionMode); + }); + + mBtnArrowDown.setOnClickListener(v -> { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_DOWN, mSelectionMode); + }); + + mBtnArrowRight.setOnClickListener(v -> { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_RIGHT, mSelectionMode); + }); + + mBtnDelete.setOnClickListener(v -> { + if (mListener != null) mListener.onCodeInput(KeyCode.FORWARD_DELETE); + }); + } + + public void setTextEditListener(TextEditListener listener) { + mListener = listener; + } + + public void applyColors(Colors colors) { + colors.setBackground(this, ColorType.MAIN_BACKGROUND); + + int keyTextColor = colors.get(ColorType.KEY_TEXT); + int functionalKeyTextColor = colors.get(ColorType.FUNCTIONAL_KEY_TEXT); + int keyIconColor = colors.get(ColorType.KEY_ICON); + + // Apply background and text colors to Action Buttons + setKeyStyle(mBtnSelectAll, colors, false, keyTextColor); + setKeyStyle(mBtnSelect, colors, mSelectionMode, mSelectionMode ? functionalKeyTextColor : keyTextColor); + setKeyStyle(mBtnCut, colors, false, keyTextColor); + setKeyStyle(mBtnCopy, colors, false, keyTextColor); + setKeyStyle(mBtnPaste, colors, false, keyTextColor); + + // Retrieve theme-aware icons + KeyboardSwitcher switcher = KeyboardSwitcher.getInstance(); + KeyboardIconsSet iconsSet = (switcher != null && switcher.getKeyboard() != null) ? switcher.getKeyboard().mIconsSet : null; + + setIconKeyStyle(mBtnClose, iconsSet, "close_history", colors, false, keyIconColor); + setIconKeyStyle(mBtnHome, iconsSet, "page_start", colors, false, keyIconColor); + setIconKeyStyle(mBtnWordLeft, iconsSet, "word_left", colors, false, keyIconColor); + setIconKeyStyle(mBtnArrowUp, iconsSet, "up", colors, false, keyIconColor); + setIconKeyStyle(mBtnWordRight, iconsSet, "word_right", colors, false, keyIconColor); + setIconKeyStyle(mBtnEnd, iconsSet, "page_end", colors, false, keyIconColor); + setIconKeyStyle(mBtnBackspace, iconsSet, "delete_key", colors, false, keyIconColor); + setIconKeyStyle(mBtnArrowLeft, iconsSet, "left", colors, false, keyIconColor); + setIconKeyStyle(mBtnArrowDown, iconsSet, "down", colors, false, keyIconColor); + setIconKeyStyle(mBtnArrowRight, iconsSet, "right", colors, false, keyIconColor); + setIconKeyStyle(mBtnDelete, iconsSet, "clear_clipboard", colors, false, keyIconColor); + } + + private void setKeyStyle(TextView textView, Colors colors, boolean isHighlighted, int textColor) { + textView.setBackground(createKeyBackground(colors, isHighlighted)); + textView.setTextColor(textColor); + } + + private void setIconKeyStyle(ImageView imageView, KeyboardIconsSet iconsSet, String iconName, Colors colors, boolean isHighlighted, int iconColor) { + imageView.setBackground(createKeyBackground(colors, isHighlighted)); + if (iconsSet != null) { + Drawable icon = iconsSet.getIconDrawable(iconName); + if (icon != null) { + Drawable mutated = icon.mutate(); + mutated.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + imageView.setImageDrawable(mutated); + } + } + } + + private Drawable createKeyBackground(Colors colors, boolean isHighlighted) { + float density = getContext().getResources().getDisplayMetrics().density; + GradientDrawable gd = new GradientDrawable(); + gd.setShape(GradientDrawable.RECTANGLE); + gd.setCornerRadius(6f * density); + + ColorType colorType = isHighlighted ? ColorType.FUNCTIONAL_KEY_BACKGROUND : ColorType.KEY_BACKGROUND; + gd.setColor(colors.get(colorType)); + return gd; + } +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index 6d955ffeb..5ae3b7ebd 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -4,6 +4,8 @@ import android.annotation.SuppressLint; import android.content.Context; import android.graphics.drawable.GradientDrawable; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.MotionEvent; @@ -11,8 +13,10 @@ import android.view.View; import android.os.Handler; import android.os.Looper; +import android.widget.ImageView; import android.widget.LinearLayout; +import helium314.keyboard.keyboard.internal.KeyboardIconsSet; import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; import helium314.keyboard.latin.R; import helium314.keyboard.latin.common.ColorType; @@ -41,10 +45,12 @@ public interface TouchpadListener { void onThreeFingerSwipeRight(); void onThreeFingerSwipeUp(); void onThreeFingerSwipeDown(); + void onClose(); } private TouchpadListener mListener; private View mTouchpadSurface; + private ImageView mBtnClose; private GestureDetector mGestureDetector; // State @@ -164,6 +170,12 @@ private void init(Context context) { LayoutInflater.from(context).inflate(R.layout.touchpad_view, this, true); mTouchpadSurface = findViewById(R.id.touchpad_surface); + mBtnClose = findViewById(R.id.btn_close_touchpad); + mBtnClose.setOnClickListener(v -> { + if (mListener != null) { + mListener.onClose(); + } + }); mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { @Override @@ -205,6 +217,30 @@ public void applyColors(Colors colors) { // Root background colors.setBackground(this, ColorType.MAIN_BACKGROUND); applySurfaceColor(); + + // Style the close button + if (mBtnClose != null) { + KeyboardSwitcher switcher = KeyboardSwitcher.getInstance(); + KeyboardIconsSet iconsSet = (switcher != null && switcher.getKeyboard() != null) ? switcher.getKeyboard().mIconsSet : null; + int keyIconColor = colors.get(ColorType.KEY_ICON); + + // Set rounded background for the close button + float density = getContext().getResources().getDisplayMetrics().density; + GradientDrawable gd = new GradientDrawable(); + gd.setShape(GradientDrawable.RECTANGLE); + gd.setCornerRadius(6f * density); + gd.setColor(colors.get(ColorType.KEY_BACKGROUND)); + mBtnClose.setBackground(gd); + + if (iconsSet != null) { + Drawable icon = iconsSet.getIconDrawable("close_history"); + if (icon != null) { + Drawable mutated = icon.mutate(); + mutated.setColorFilter(keyIconColor, PorterDuff.Mode.SRC_IN); + mBtnClose.setImageDrawable(mutated); + } + } + } } private void applySurfaceColor() { @@ -401,24 +437,24 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_UP: - android.util.Log.i("TouchpadView", "ACTION_POINTER_UP: pointerCount=" + pointerCount); - if (pointerCount == 2) { - removeCallbacks(mTwoFingerLongPressRunnable); - if (mIsTwoFingerLongPress) { - mIsTwoFingerLongPress = false; - mIsTwoFingerScroll = false; - mIsTwoFingerTap = false; - return true; - } - if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { - mTwoFingerTapCount++; - removeCallbacks(mTwoFingerTapRunnable); - postDelayed(mTwoFingerTapRunnable, 250); - } + android.util.Log.i("TouchpadView", "ACTION_POINTER_UP: pointerCount=" + pointerCount); + if (pointerCount == 2) { + removeCallbacks(mTwoFingerLongPressRunnable); + if (mIsTwoFingerLongPress) { + mIsTwoFingerLongPress = false; mIsTwoFingerScroll = false; mIsTwoFingerTap = false; + return true; } - return true; + if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { + mTwoFingerTapCount++; + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerTapRunnable, 250); + } + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + } + return true; } return true; }); 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 6a9770945..b7ed505a8 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -504,7 +504,7 @@ public void afterTextChanged(Editable s) { downloadBtn.setTextSize(12); // Keep it small to fit downloadBtn.setAllCaps(false); downloadBtn.setOnClickListener(v -> { - if ("standard".equals(BuildConfig.FLAVOR)) { + if ("standard".equals(BuildConfig.FLAVOR) || "standardfull".equals(BuildConfig.FLAVOR)) { downloadEmojiDictionary(); downloadBtn.setText("Downloading..."); downloadBtn.setEnabled(false); @@ -1039,7 +1039,7 @@ private void updateSplitToolbarEmojiSuggestions() { if (sDictionaryFacilitator == null) { // ponytail: show download button on suggestion strip in split mode if dictionary is missing stripView.setEmojiDownloadButton(() -> { - if ("standard".equals(BuildConfig.FLAVOR)) { + if ("standard".equals(BuildConfig.FLAVOR) || "standardfull".equals(BuildConfig.FLAVOR)) { downloadEmojiDictionary(); mIsDownloadingEmojiDict = true; updateSplitToolbarEmojiSuggestions(); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 0cd860269..ec6027d33 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -161,6 +161,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FLOATING -> R.drawable.ic_drag_indicator ToolbarKey.INCOGNITO -> R.drawable.ic_incognito_final ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad + ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect ToolbarKey.AUTOSPACE -> R.drawable.ic_autospace ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap @@ -244,6 +245,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FLOATING -> R.drawable.ic_drag_indicator ToolbarKey.INCOGNITO -> R.drawable.ic_incognito_final ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad + ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect ToolbarKey.AUTOSPACE -> R.drawable.ic_autospace ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap @@ -327,6 +329,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FLOATING -> R.drawable.ic_drag_indicator ToolbarKey.INCOGNITO -> R.drawable.ic_incognito_final ToolbarKey.TOUCHPAD -> R.drawable.ic_touchpad_rounded + ToolbarKey.TEXT_EDIT -> R.drawable.ic_text_edit ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect_rounded ToolbarKey.AUTOSPACE -> R.drawable.ic_autospace_rounded ToolbarKey.AUTO_CAP -> R.drawable.ic_auto_cap_rounded diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index ac3227cc9..8c1ef3cfb 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -30,9 +30,9 @@ object KeyCode { const val FN = -5 const val FN_LOCK = -6 const val DELETE = -7 - //const val DELETE_WORD = -8 + const val DELETE_WORD = -8 const val FORWARD_DELETE = -9 - //const val FORWARD_DELETE_WORD = -10 + const val FORWARD_DELETE_WORD = -10 const val SHIFT = -11 const val CAPS_LOCK = -13 @@ -101,6 +101,7 @@ object KeyCode { const val URI_COMPONENT_TLD = -255 const val SETTINGS = -301 + const val TOGGLE_TEXT_EDIT_MODE = -305 const val CURRENCY_SLOT_1 = -801 const val CURRENCY_SLOT_2 = -802 @@ -212,7 +213,7 @@ object KeyCode { fun Int.checkAndConvertCode(): Int = if (this > 0) this else when (this) { // working CURRENCY_SLOT_1, CURRENCY_SLOT_2, CURRENCY_SLOT_3, CURRENCY_SLOT_4, CURRENCY_SLOT_5, CURRENCY_SLOT_6, - VOICE_INPUT, LANGUAGE_SWITCH, SETTINGS, DELETE, ALPHA, SYMBOL, EMOJI, CLIPBOARD, CLIPBOARD_CUT, UNDO, + VOICE_INPUT, LANGUAGE_SWITCH, SETTINGS, DELETE, DELETE_WORD, FORWARD_DELETE, FORWARD_DELETE_WORD, ALPHA, SYMBOL, EMOJI, CLIPBOARD, CLIPBOARD_CUT, UNDO, REDO, ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, CLIPBOARD_COPY, CLIPBOARD_PASTE, CLIPBOARD_SELECT_ALL, CLIPBOARD_SELECT_WORD, TOGGLE_INCOGNITO_MODE, TOGGLE_AUTOCORRECT, TOGGLE_AUTOSPACE, TOGGLE_AUTO_CAP, TOGGLE_FORCE_AUTO_CAP, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, FORWARD_DELETE, MOVE_START_OF_LINE, MOVE_END_OF_LINE, @@ -227,7 +228,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING, CLEAR_HANDWRITING + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, HANDWRITING, CLEAR_HANDWRITING -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index f6268a03a..ae15f16f8 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -26,6 +26,7 @@ import android.net.Uri import android.os.Handler import android.os.Looper import kotlin.concurrent.thread +import helium314.keyboard.latin.utils.prefs class ClipboardHistoryManager( private val latinIME: LatinIME @@ -160,6 +161,8 @@ class ClipboardHistoryManager( mainHandler.postDelayed({ updateLatestScreenshotCache { dontShowCurrentSuggestion = false + val prefs = latinIME.prefs() + prefs.edit().remove("last_dismissed_screenshot_uri").apply() latinIME.setNeutralSuggestionStrip() } }, 1000) @@ -204,6 +207,8 @@ class ClipboardHistoryManager( if (latinIME.mSettings.current.mClipboardHistoryEnabled) { thread { fetchPrimaryClip() } dontShowCurrentSuggestion = false + val prefs = latinIME.prefs() + prefs.edit().remove("last_dismissed_clipboard_text").apply() } } @@ -411,6 +416,9 @@ class ClipboardHistoryManager( if (System.currentTimeMillis() - timeStamp > RECENT_TIME_MILLIS) return null val content = clipItem.coerceToText(latinIME) if (TextUtils.isEmpty(content)) return null + val prefs = latinIME.prefs() + val lastDismissedText = prefs.getString("last_dismissed_clipboard_text", "") + if (content.toString() == lastDismissedText) return null val inputType = editorInfo?.inputType ?: InputType.TYPE_NULL if (InputTypeUtils.isNumberInputType(inputType) && !content.isValidNumber()) return null @@ -430,7 +438,11 @@ class ClipboardHistoryManager( } val closeButton = binding.clipboardSuggestionClose closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) - closeButton.setOnClickListener { removeClipboardSuggestion() } + closeButton.setOnClickListener { + val prefs = latinIME.prefs() + prefs.edit().putString("last_dismissed_clipboard_text", content.toString()).apply() + removeClipboardSuggestion() + } val colors = latinIME.mSettings.current.mColors textView.setTextColor(colors.get(ColorType.KEY_TEXT)) @@ -462,6 +474,10 @@ class ClipboardHistoryManager( return null } + val prefs = latinIME.prefs() + val lastDismissedScreenshotUri = prefs.getString("last_dismissed_screenshot_uri", "") + if (screenshotInfo.uri.toString() == lastDismissedScreenshotUri) return null + val diff = System.currentTimeMillis() - screenshotInfo.dateAdded if (diff >= RECENT_SCREENSHOT_TIME_MILLIS) { cachedScreenshotInfo = null @@ -517,6 +533,8 @@ class ClipboardHistoryManager( val closeButton = binding.clipboardSuggestionClose closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard.mIconsSet.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) closeButton.setOnClickListener { + val prefs = latinIME.prefs() + prefs.edit().putString("last_dismissed_screenshot_uri", contentUri.toString()).apply() dontShowCurrentSuggestion = true lastSuggestedScreenshotUri = contentUri.toString() removeClipboardSuggestion() @@ -572,7 +590,7 @@ class ClipboardHistoryManager( } companion object { - const val RECENT_TIME_MILLIS = 3 * 60 * 1000L // 3 minutes (for clipboard suggestions) - const val RECENT_SCREENSHOT_TIME_MILLIS = 4 * 60 * 1000L // 4 minutes + const val RECENT_TIME_MILLIS = 1 * 60 * 1000L // 1 minute (for clipboard suggestions) + const val RECENT_SCREENSHOT_TIME_MILLIS = 1 * 60 * 1000L // 1 minute } } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 589707036..cf6ede855 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1788,6 +1788,30 @@ public void setNeutralSuggestionStrip() { mSuggestionStripView.setToolbarVisibility(false); return; } + if (currentSettings.mBigramPredictionEnabled) { + mInputLogic.getSuggestedWords(SuggestedWords.INPUT_STYLE_PREDICTION, 0, new Suggest.OnGetSuggestedWordsCallback() { + @Override + public void onGetSuggestedWords(SuggestedWords suggestedWords) { + if (suggestedWords != null && !suggestedWords.isEmpty()) { + setSuggestedWords(suggestedWords); + if (hasSuggestionStripView()) { + if (currentSettings.mAutoShowToolbarOnSelect && mInputLogic.getConnection().hasSelection()) { + mSuggestionStripView.setToolbarVisibility(true); + } else if (currentSettings.mAutoShowToolbarOnSelect) { + mSuggestionStripView.setToolbarVisibility(mSuggestionStripView.isToolbarManuallyOpen()); + } + } + } else { + setNeutralPunctuationSuggestionStrip(currentSettings); + } + } + }); + } else { + setNeutralPunctuationSuggestionStrip(currentSettings); + } + } + + private void setNeutralPunctuationSuggestionStrip(final SettingsValues currentSettings) { final SuggestedWords neutralSuggestions = currentSettings.mSuggestPunctuation ? currentSettings.mSpacingAndPunctuations.mSuggestPuncList : SuggestedWords.getEmptyInstance(); diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt index 38e8c778d..01bc3ddd2 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt @@ -69,7 +69,8 @@ object DictionaryFactory { val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(file) if (header != null) { val prefs = context.prefs() - if (!prefs.getBoolean("pref_dict_enabled_${header.mIdString}", true)) { + val mainPrefKey = "pref_dict_enabled_main:${header.mIdString.substringAfter(":")}" + if (!prefs.getBoolean(mainPrefKey, true) || !prefs.getBoolean("pref_dict_enabled_${header.mIdString}", true)) { Log.i("DictionaryFactory", "skipping disabled dictionary ${header.mIdString}") return } diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java index 4c6e6c279..d42e180bb 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/UserBinaryDictionary.java @@ -16,6 +16,7 @@ import com.android.inputmethod.latin.BinaryDictionary; +import helium314.keyboard.latin.NgramContext; import helium314.keyboard.latin.utils.Log; import helium314.keyboard.latin.utils.SubtypeLocaleUtils; @@ -207,6 +208,28 @@ private void addWordsLocked(final Cursor cursor) { false /* isPossiblyOffensive */, BinaryDictionary.NOT_A_VALID_TIMESTAMP); } + // ponytail: split phrase into unigrams and n-grams for next-word prediction + final String[] parts = word.split("\\s+"); + if (parts.length > 1) { + for (final String part : parts) { + if (part.length() <= MAX_WORD_LENGTH && !part.isEmpty() && !part.equals(word)) { + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addUnigramLocked(part, adjustedFrequency, null, 0, false, false, BinaryDictionary.NOT_A_VALID_TIMESTAMP); + } + } + for (int i = 1; i < parts.length; i++) { + final String targetWord = parts[i]; + if (targetWord.length() <= MAX_WORD_LENGTH && !targetWord.isEmpty()) { + final int contextSize = Math.min(i, BinaryDictionary.MAX_PREV_WORD_COUNT_FOR_N_GRAM); + final NgramContext.WordInfo[] prevWords = new NgramContext.WordInfo[contextSize]; + for (int j = 0; j < contextSize; j++) { + prevWords[j] = new NgramContext.WordInfo(parts[i - 1 - j]); + } + runGCIfRequiredLocked(true /* mindsBlockByGC */); + addNgramEntryLocked(new NgramContext(prevWords), targetWord, adjustedFrequency, BinaryDictionary.NOT_A_VALID_TIMESTAMP); + } + } + } } cursor.moveToNext(); } diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt index 9731bd67d..9e3ad6d93 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -47,7 +47,7 @@ object HandwritingLoader { recognizer.init(context) activeRecognizer = recognizer return recognizer - } catch (e: Exception) { + } catch (e: Throwable) { Log.e("HandwritingLoader", "Failed to load handwriting plugin", e) } return null @@ -100,7 +100,7 @@ object HandwritingLoader { context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, true).apply() activeRecognizer = recognizer return true - } catch (e: Exception) { + } catch (e: Throwable) { Log.e("HandwritingLoader", "Failed to import plugin APK", e) // Cleanup on failure try { @@ -125,4 +125,4 @@ object HandwritingLoader { context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() activeRecognizer = null } -} +} \ No newline at end of file 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 56331ff57..73a382541 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -144,7 +144,7 @@ class HandwritingView @JvmOverloads constructor( button.setTextColor(colors.get(ColorType.KEY_TEXT)) // ponytail: download plugin directly on standard flavor, otherwise go to Settings - if ("standard" == helium314.keyboard.latin.BuildConfig.FLAVOR) { + if ("standardfull" == helium314.keyboard.latin.BuildConfig.FLAVOR) { button.text = "Download Plugin" button.setOnClickListener { downloadPlugin(button) @@ -461,4 +461,4 @@ class HandwritingView @JvmOverloads constructor( } } } -} +} \ No newline at end of file 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 3ea61ce4d..46f561678 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -564,6 +564,15 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, fin } } + if (oldSelStart != newSelStart || oldSelEnd != newSelEnd) { + if (newSelStart != mLastExpandedCursorPosition) { + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + } + } + final boolean selectionChangedOrSafeToReset = oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection // changed || !mWordComposer.isComposingWord(); // safe to reset @@ -2214,26 +2223,17 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME) && helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) { final String typedWord = mWordComposer.getTypedWord(); - final String prefix = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getPrefix(mLatinIME); - if (prefix.isEmpty()) { - final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(typedWord, mLatinIME); - if (expanded != null) { - commitExpandedText(typedWord, expanded); - resetComposingState(true); - } - } else { - final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); - if (textBefore != null) { - final String textStr = textBefore.toString(); - final String targetSuffix = prefix + typedWord; - if (textStr.toLowerCase(java.util.Locale.US).endsWith(targetSuffix.toLowerCase(java.util.Locale.US))) { - final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(targetSuffix, mLatinIME); - if (expanded != null) { - mConnection.deleteTextBeforeCursor(prefix.length()); - commitExpandedText(targetSuffix, expanded); - resetComposingState(true); - } + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final String textStr = textBefore.toString(); + final helium314.keyboard.latin.utils.TextExpanderUtils.ExpandedResult result = + helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWordForTyped(typedWord, textStr, mLatinIME); + if (result != null) { + if (result.getPrefixLength() > 0) { + mConnection.deleteTextBeforeCursor(result.getPrefixLength()); } + commitExpandedText(result.getMatchedString(), result.getExpandedText()); + resetComposingState(true); } } } @@ -2519,6 +2519,28 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu } } + if (mLastExpandedText != null && !event.isKeyRepeat()) { + final int expectedCursor = mConnection.getExpectedSelectionEnd(); + if (expectedCursor == mLastExpandedCursorPosition) { + final int beforeLen = mLastExpandedCursorOffset; + final int afterLen = mLastExpandedText.length() - beforeLen; + final CharSequence textBefore = mConnection.getTextBeforeCursor(beforeLen, 0); + final CharSequence textAfter = mConnection.getTextAfterCursor(afterLen, 0); + final String expectedBefore = mLastExpandedText.substring(0, beforeLen); + final String expectedAfter = mLastExpandedText.substring(beforeLen); + if (textBefore != null && textBefore.toString().equals(expectedBefore) + && textAfter != null && textAfter.toString().equals(expectedAfter)) { + mConnection.setSelection(expectedCursor - beforeLen, expectedCursor + afterLen); + mConnection.commitText(mLastShortcutText, 1); + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + return; + } + } + } + // In many cases after backspace, we need to update the shift state. Normally we // need // to do this right away to avoid the shift state being out of date in case the @@ -4265,29 +4287,17 @@ private void commitChosenWord(final SettingsValues settingsValues, final String // can't find any drawback (performance, neither when setting nor when reading) final boolean isEnabled = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME); if (isEnabled) { - final String prefix = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getPrefix(mLatinIME); - if (prefix.isEmpty()) { - final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(chosenWord, mLatinIME); - if (expanded != null) { + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final String textStr = textBefore.toString(); + final helium314.keyboard.latin.utils.TextExpanderUtils.ExpandedResult result = + helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWordForTyped(chosenWord, textStr, mLatinIME); + if (result != null) { mConnection.commitText(getTextWithSuggestionSpan(mLatinIME, chosenWord, mSuggestedWords, getDictionaryFacilitatorLocale()), 1); - mConnection.deleteTextBeforeCursor(chosenWord.length()); - commitExpandedText(chosenWord, expanded); + mConnection.deleteTextBeforeCursor(result.getPrefixLength() + chosenWord.length()); + commitExpandedText(result.getMatchedString(), result.getExpandedText()); return; - } - } else { - final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); - if (textBefore != null) { - final String textStr = textBefore.toString(); - final String targetSuffix = prefix + chosenWord; - if (textStr.toLowerCase(java.util.Locale.US).endsWith(targetSuffix.toLowerCase(java.util.Locale.US))) { - final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(targetSuffix, mLatinIME); - if (expanded != null) { - mConnection.commitText(getTextWithSuggestionSpan(mLatinIME, chosenWord, mSuggestedWords, getDictionaryFacilitatorLocale()), 1); - mConnection.deleteTextBeforeCursor(prefix.length() + chosenWord.length()); - commitExpandedText(targetSuffix, expanded); - return; - } - } + } } } @@ -4409,7 +4419,7 @@ public boolean retryResetCachesAndReturnSuccess(final boolean tryResumeSuggestio // we used to provide keyboard, settingsValues and keyboardShiftMode, but every // time read it from current instance anyway - void getSuggestedWords(final int inputStyle, final int sequenceNumber, final OnGetSuggestedWordsCallback callback) { + public void getSuggestedWords(final int inputStyle, final int sequenceNumber, final OnGetSuggestedWordsCallback callback) { final Keyboard keyboard = KeyboardSwitcher.getInstance().getKeyboard(); if (keyboard == null) { callback.onGetSuggestedWords(SuggestedWords.getEmptyInstance()); diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index c0c14c172..4fa40c105 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -185,6 +185,8 @@ object Defaults { const val PREF_LANGUAGE_SWIPE_DISTANCE = 5 const val PREF_TOUCHPAD_SENSITIVITY = 50 const val PREF_TOUCHPAD_EDGE_SCROLL = false + + const val PREF_TOUCHPAD_FULLSCREEN = false const val PREF_FORCE_AUTO_CAPS = false const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 64f1c3b70..3755f0b3f 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -216,6 +216,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_LANGUAGE_SWIPE_DISTANCE = "language_swipe_distance"; public static final String PREF_TOUCHPAD_SENSITIVITY = "touchpad_sensitivity"; public static final String PREF_TOUCHPAD_EDGE_SCROLL = "touchpad_edge_scroll"; + public static final String PREF_TOUCHPAD_FULLSCREEN = "touchpad_fullscreen"; public static final String PREF_PERSIST_FLOATING_KEYBOARD = "persist_floating_keyboard"; public static final String PREF_FORCE_AUTO_CAPS = "force_auto_caps"; public static final String PREF_OFFLINE_TEMP = "offline_temp"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 5101f5a71..b4e57e442 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -83,6 +83,7 @@ public class SettingsValues { public final int mLanguageSwipeDistance; public final int mTouchpadSensitivity; public final boolean mTouchpadEdgeScroll; + public final boolean mTouchpadFullscreen; public final boolean mForceAutoCaps; public final boolean mDeleteSwipeEnabled; public final boolean mShortcutRowsEnabled; @@ -442,6 +443,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_TOUCHPAD_SENSITIVITY); mTouchpadEdgeScroll = prefs.getBoolean(Settings.PREF_TOUCHPAD_EDGE_SCROLL, Defaults.PREF_TOUCHPAD_EDGE_SCROLL); + mTouchpadFullscreen = prefs.getBoolean(Settings.PREF_TOUCHPAD_FULLSCREEN, + Defaults.PREF_TOUCHPAD_FULLSCREEN); mForceAutoCaps = prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS); mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE); mShortcutRowsEnabled = prefs.getBoolean(Settings.PREF_SHORTCUT_ROWS, Defaults.PREF_SHORTCUT_ROWS); diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java index 37cce0874..13c737a0f 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java @@ -320,6 +320,9 @@ private int getSuggestionTextColor(final SuggestedWords suggestedWords, final int color; if (indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION && suggestedWords.mWillAutoCorrect) { color = mColorAutoCorrect; + } else if (suggestedWords.isPrediction() && indexInSuggestedWords == 0) { + // ponytail: first word prediction should be colored active/bright + color = mColorAutoCorrect; } else if (isTypedWord && suggestedWords.mTypedWordValid) { color = mColorValidTypedWord; } else if (isTypedWord) { 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 de0e41660..4b00aba67 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -71,7 +71,6 @@ 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 import kotlin.math.min @@ -698,30 +697,21 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) return true } Settings.getValues().mColors.setColor(icon, ColorType.REMOVE_SUGGESTION_ICON) - val w = icon.intrinsicWidth - val h = icon.intrinsicHeight wordView.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) wordView.ellipsize = TextUtils.TruncateAt.END - val downOk = AtomicBoolean(false) - wordView.setOnTouchListener { _, motionEvent -> - if (motionEvent.action == MotionEvent.ACTION_UP && downOk.get()) { - val x = motionEvent.x - val y = motionEvent.y - if (0 < x && x < w && 0 < y && y < h) { - removeSuggestion(wordView) - wordView.cancelLongPress() - wordView.isPressed = false - return@setOnTouchListener true - } - } else if (motionEvent.action == MotionEvent.ACTION_DOWN) { - val x = motionEvent.x - val y = motionEvent.y - if (0 < x && x < w && 0 < y && y < h) { - downOk.set(true) - } - } - false + // ponytail: entire word view is now the delete target, not just the tiny icon + val savedTag = wordView.tag + // Replace click listener to delete on any tap + wordView.setOnClickListener { + removeSuggestion(wordView) } + // Auto-dismiss delete mode after 3s, restore normal behavior + wordView.postDelayed({ + wordView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null) + wordView.setOnTouchListener(null) + wordView.tag = savedTag + wordView.setOnClickListener(this) + }, 3000) } if (DebugFlags.DEBUG_ENABLED && (isShowingMoreSuggestionPanel || !showMoreSuggestions())) { showSourceDict(wordView) @@ -925,7 +915,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } // ponytail: show/hide dictionary download button if dictionary is missing - if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") { + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard" || helium314.keyboard.latin.BuildConfig.FLAVOR == "standardfull") { val currentLocale = SubtypeSettings.getSelectedSubtype(context.prefs()).locale() if (isMainDictionaryMissing(context, currentLocale) && !hideToolbarKeys) { if (dictDownloadButton == null) { @@ -964,7 +954,6 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } else { dictDownloadButton?.isVisible = false } - isExternalSuggestionVisible = false } @@ -1045,64 +1034,11 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } suggestionsStrip.isVisible = true + // ponytail: no fallback suggestions to keep it clean and minimal 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) { - val child = suggestionsStrip.getChildAt(i) - if (child.tag != PLACEHOLDER_TAG && child is TextView && !child.text.isNullOrEmpty()) { - hasRealSuggestions = true - break - } - } - - if (hasRealSuggestions) { - // Real suggestions exist, remove placeholder - if (placeholder != null) suggestionsStrip.removeView(placeholder) - } else { - // No suggestions, show random placeholder suggestions - if (placeholder == null) { - val placeholderContainer = LinearLayout(context) - placeholderContainer.tag = PLACEHOLDER_TAG - placeholderContainer.orientation = LinearLayout.HORIZONTAL - placeholderContainer.layoutParams = LinearLayout.LayoutParams( - 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 - suggestionView.gravity = android.view.Gravity.CENTER - suggestionView.alpha = 0.4f // More transparent to indicate they're placeholders - if (customTypeface != null) - 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, - 1f - ) - suggestionView.layoutParams = params - placeholderContainer.addView(suggestionView) - } - - suggestionsStrip.addView(placeholderContainer) - } + if (placeholder != null) { + suggestionsStrip.removeView(placeholder) } } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt index b23d5e90a..03ab34a48 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt @@ -112,8 +112,22 @@ object DictionaryInfoUtils { fun getCachedDictForLocaleAndType(locale: Locale, type: String, context: Context): File? = getCachedDictsForLocale(locale, context).firstOrNull { it.name.substringBefore("_") == type } - fun getCachedDictsForLocale(locale: Locale, context: Context) = - getCacheDirectoryForLocale(locale, context)?.let { File(it).listFiles() }.orEmpty() + fun getCachedDictsForLocale(locale: Locale, context: Context): Array { + val exactDir = getCacheDirectoryForLocale(locale, context)?.let { File(it) } + val exactFiles = exactDir?.listFiles() + if (exactFiles?.any { it.name.endsWith(USER_DICTIONARY_SUFFIX) || it.name.startsWith(MAIN_DICT_PREFIX) || it.name == MAIN_DICT_FILE_NAME } == true) { + return exactFiles + } + if (locale.country.isNotEmpty() || locale.variant.isNotEmpty()) { + val fallbackLocale = Locale(locale.language) + val fallbackDir = getCacheDirectoryForLocale(fallbackLocale, context)?.let { File(it) } + val fallbackFiles = fallbackDir?.listFiles() + if (fallbackFiles?.any { it.name.endsWith(USER_DICTIONARY_SUFFIX) || it.name.startsWith(MAIN_DICT_PREFIX) || it.name == MAIN_DICT_FILE_NAME } == true) { + return fallbackFiles + } + } + return exactFiles ?: emptyArray() + } fun getDictionaryFileHeaderOrNull(file: File): DictionaryHeader? { return try { 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 667a12c84..c66942fe7 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt @@ -53,25 +53,54 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner fun getDictionaryLocales(context: Context): MutableSet { val locales = HashSet() + // ponytail: migrate legacy incorrectly-named dictionary folders (gb, au, ca) to en-GB, en-AU, en-CA + val dictDir = File(DictionaryInfoUtils.getWordListCacheDirectory(context)) + if (dictDir.exists() && dictDir.isDirectory) { + val legacyMap = mapOf("gb" to "en-GB", "au" to "en-AU", "ca" to "en-CA") + legacyMap.forEach { (legacy, correct) -> + val legacyFolder = File(dictDir, legacy) + if (legacyFolder.exists() && legacyFolder.isDirectory) { + val correctFolder = File(dictDir, correct) + if (!correctFolder.exists()) { + legacyFolder.renameTo(correctFolder) + } else { + legacyFolder.deleteRecursively() + } + } + } + } + + // ponytail: include enabled locales and multilingual secondary locales + val enabled = SubtypeSettings.getEnabledSubtypes(true) + val enabledLocales = HashSet() + enabled.forEach { subtype -> + enabledLocales.add(subtype.locale()) + getSecondaryLocales(subtype.extraValue).forEach { enabledLocales.add(it) } + } + android.util.Log.i("DictionaryUtils", "getDictionaryLocales: enabledLocales=$enabledLocales") + // ponytail: get cached dictionaries: extracted or user-added/downloaded dictionaries DictionaryInfoUtils.getCacheDirectories(context).forEach { directory -> if (!hasAnythingOtherThanExtractedMainDictionary(context, directory)) return@forEach val locale = DictionaryInfoUtils.getWordListIdFromFileName(directory.name).constructLocale() + val isEnabled = enabledLocales.contains(locale) + val hasEnabledLanguage = enabledLocales.any { it.language == locale.language } + android.util.Log.i("DictionaryUtils", "Cache loop: locale=$locale, isEnabled=$isEnabled, hasEnabledLanguage=$hasEnabledLanguage") + if (!isEnabled && hasEnabledLanguage) return@forEach locales.add(locale) } // get assets dictionaries val assetsDictionaryList = DictionaryInfoUtils.getAssetsDictionaryList(context) if (assetsDictionaryList != null) { for (dictionary in assetsDictionaryList) { - locales.add(DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dictionary)) + val locale = DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dictionary) + val isEnabled = enabledLocales.contains(locale) + val hasEnabledLanguage = enabledLocales.any { it.language == locale.language } + if (!isEnabled && hasEnabledLanguage) continue + locales.add(locale) } } - // 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) } - } + locales.addAll(enabledLocales) return locales } @@ -91,7 +120,7 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline var annotatedString = message.htmlToAnnotated() // 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") { + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard" || helium314.keyboard.latin.BuildConfig.FLAVOR == "standardfull") { getKnownDictionariesForLocale(locale, context) } else emptyList() } @@ -237,7 +266,12 @@ fun downloadDictionary(context: Context, locale: Locale, type: String, linkUrl: 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) } + // ponytail: extract the specific dictionary locale from the download link to avoid directory collision + val dictLocale = remember(link) { + val fileName = link.substringAfterLast("/") + fileName.substringAfter("_").substringBefore(".dict").constructLocale() + } + val cacheDir = remember(dictLocale) { DictionaryInfoUtils.getCacheDirectoryForLocale(dictLocale, 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) } @@ -274,7 +308,7 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefr } else { androidx.compose.material3.TextButton(onClick = { downloading = true - downloadDictionary(ctx, locale, type, link) { success -> + downloadDictionary(ctx, dictLocale, type, link) { success -> downloading = false if (success) { exists = true @@ -301,11 +335,14 @@ fun isMainDictionaryMissing(context: Context, locale: Locale): Boolean { 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 + var cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) } + var hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && cacheDir.listFiles()?.any { it.name == "main.dict" } == true + if (!hasMain && (locale.country.isNotEmpty() || locale.variant.isNotEmpty())) { + val fallbackLocale = Locale(locale.language) + cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(fallbackLocale, context)?.let { File(it) } + hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && 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" } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index eff85bce3..9df8b7bdd 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -28,33 +28,54 @@ object TextExpanderUtils { } fun getPrefix(context: Context): String { - return context.prefs().getString(PREF_PREFIX, "") ?: "" + return "" } - fun getShortcuts(context: Context): Map { + data class ShortcutEntry( + val template: String, + val prefix: String = "" + ) + + data class ExpandedResult( + val expandedText: String, + val prefixLength: Int, + val matchedString: String + ) + + fun getShortcuts(context: Context): Map { val jsonStr = context.prefs().getString(PREF_DATA, "{}") ?: "{}" - val map = mutableMapOf() + val map = mutableMapOf() try { val json = JSONObject(jsonStr) val keys = json.keys() while (keys.hasNext()) { val key = keys.next() - map[key] = json.getString(key) + val valueObj = json.get(key) + if (valueObj is JSONObject) { + val template = valueObj.optString("template", "") + val prefix = valueObj.optString("prefix", "") + map[key] = ShortcutEntry(template, prefix) + } else { + map[key] = ShortcutEntry(valueObj.toString(), "") + } } - } catch (e: Exception) { + } catch (e: java.lang.Exception) { // fallback } return map } - fun saveShortcuts(context: Context, map: Map) { + fun saveShortcuts(context: Context, map: Map) { try { val json = JSONObject() - for ((key, value) in map) { - json.put(key, value) + for ((key, entry) in map) { + val obj = JSONObject() + obj.put("template", entry.template) + obj.put("prefix", entry.prefix) + json.put(key, obj) } context.prefs().edit().putString(PREF_DATA, json.toString()).apply() - } catch (e: Exception) { + } catch (e: java.lang.Exception) { // fail silently } } @@ -190,38 +211,49 @@ object TextExpanderUtils { return result } - fun getExpandedWord(word: String?, context: Context): String? { - if (word == null || !isEnabled(context)) return null - - val prefix = getPrefix(context) - if (prefix.isNotEmpty() && !word.startsWith(prefix)) return null - - val shortcut = if (prefix.isNotEmpty()) word.substring(prefix.length) else word - if (shortcut.isEmpty()) return null - + fun getExpandedWordForTyped(word: String?, textBeforeCursor: String?, context: Context): ExpandedResult? { + if (word == null || textBeforeCursor == null || !isEnabled(context)) return null val shortcuts = getShortcuts(context) - // Check exact match or lowercase match - val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] - if (template != null) { - return expand(template, context) - } - // Check regex matches - for ((key, value) in shortcuts) { - if (key.startsWith(REGEX_PREFIX)) { - val patternStr = key.substring(REGEX_PREFIX.length) + for ((key, entry) in shortcuts) { + val isRegex = key.startsWith(REGEX_PREFIX) + val cleanKey = if (isRegex) key.substring(REGEX_PREFIX.length) else key + + if (isRegex) { + val prefix = entry.prefix + val patternStr = cleanKey try { val regex = Regex(patternStr, RegexOption.IGNORE_CASE) - if (regex.matches(shortcut)) { - val replaced = regex.replace(shortcut, value) - return expand(replaced, context) + val expectedSuffix = prefix + word + if (textBeforeCursor.endsWith(expectedSuffix, ignoreCase = true)) { + if (regex.matches(expectedSuffix)) { + val replaced = regex.replace(expectedSuffix, entry.template) + return ExpandedResult(expand(replaced, context), prefix.length, expectedSuffix) + } + } + } catch (e: java.lang.Exception) { + // ignore + } + } else { + val prefix = entry.prefix + val expectedSuffix = cleanKey + if (expectedSuffix.equals(prefix + word, ignoreCase = true)) { + if (textBeforeCursor.endsWith(expectedSuffix, ignoreCase = true)) { + return ExpandedResult(expand(entry.template, context), prefix.length, expectedSuffix) } - } catch (e: Exception) { - // ignore invalid regex } } } - + return null + } + + fun getExpandedWord(word: String?, context: Context): String? { + if (word == null || !isEnabled(context)) return null + val shortcuts = getShortcuts(context) + val entry = shortcuts[word] ?: shortcuts[word.lowercase(Locale.getDefault())] + if (entry != null && entry.prefix.isEmpty()) { + return expand(entry.template, context) + } return null } } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 54ed101bf..23c3b49b7 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -281,6 +281,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool FLOATING -> KeyCode.TOGGLE_FLOATING_KEYBOARD INCOGNITO -> KeyCode.TOGGLE_INCOGNITO_MODE TOUCHPAD -> KeyCode.TOGGLE_TOUCHPAD_MODE + TEXT_EDIT -> KeyCode.TOGGLE_TEXT_EDIT_MODE AUTOCORRECT -> KeyCode.TOGGLE_AUTOCORRECT AUTOSPACE -> KeyCode.TOGGLE_AUTOSPACE AUTO_CAP -> KeyCode.TOGGLE_AUTO_CAP @@ -341,7 +342,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, - INCOGNITO, TOUCHPAD, AUTOCORRECT, AUTOSPACE, AUTO_CAP, FORCE_AUTO_CAP, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, + INCOGNITO, TOUCHPAD, TEXT_EDIT, AUTOCORRECT, AUTOSPACE, AUTO_CAP, FORCE_AUTO_CAP, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, PROOFREAD, TRANSLATE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10 @@ -355,12 +356,12 @@ val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) // 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") + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardfull" && BuildConfig.FLAVOR != "offline") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") listOf(PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) - else if (BuildConfig.FLAVOR == "offline") + else if (BuildConfig.FLAVOR == "offline" || BuildConfig.FLAVOR == "standard") listOf(HANDWRITING) else emptyList() @@ -375,9 +376,9 @@ private val excludedKeys by lazy { 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) + "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE, TEXT_EDIT) "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE) - else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) + else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, TEXT_EDIT, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) } val others = entries.filterNot { it in default || it in excludedKeys } @@ -388,7 +389,7 @@ val defaultToolbarPref by lazy { val defaultPinnedToolbarPref by lazy { val pinnedDefault = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { "offlinelite" -> listOf(CLIPBOARD) - else -> listOf(CLIPBOARD, PROOFREAD, TOUCHPAD, FLOATING) + else -> listOf(CLIPBOARD, PROOFREAD, TOUCHPAD, TEXT_EDIT, FLOATING) } entries.filterNot { it in excludedKeys }.joinToString(Separators.ENTRY) { diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt index a38dc070b..542de0860 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") { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardfull") { 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/DictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt index c4219624a..2c5a98c5d 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt @@ -60,7 +60,10 @@ fun DictionaryDialog( val ctx = LocalContext.current 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 mainDict = dictionaries.firstOrNull { + it.name == Dictionary.TYPE_MAIN + "_" + DictionaryInfoUtils.USER_DICTIONARY_SUFFIX + || it.name == DictionaryInfoUtils.MAIN_DICT_FILE_NAME + } val addonDicts = dictionaries.filterNot { it == mainDict } val picker = dictionaryFilePicker(locale) ThreeButtonAlertDialog( @@ -71,55 +74,41 @@ fun DictionaryDialog( title = { Text(locale.localizedDisplayName(LocalResources.current)) }, content = { Column { - if (hasInternal) { - val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(ctx) - val best = internalDicts?.let { - LocaleUtils.getBestMatch(locale, it.toList()) { dict -> - DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict) - } + val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(ctx) + val best = internalDicts?.let { + LocaleUtils.getBestMatch(locale, it.toList()) { dict -> + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict) } - val internalId = best?.let { "main:" + it.substringAfter("_").substringBefore(".") } + } + val internalId = best?.let { "main:" + it.substringAfter("_").substringBefore(".") } + val mainPrefKey = "pref_dict_enabled_" + (internalId ?: "main:${locale.language}") - 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 + val prefs = ctx.prefs() + var enabled by remember { mutableStateOf(prefs.getBoolean(mainPrefKey, true)) } - if (internalId != null) { - val prefs = ctx.prefs() - val prefKey = "pref_dict_enabled_$internalId" - var enabled by remember { mutableStateOf(prefs.getBoolean(prefKey, true)) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = bottomPadding) - ) { - Switch( - checked = enabled && (mainDict == null), - enabled = mainDict == null, - onCheckedChange = { isChecked -> - enabled = isChecked - prefs.edit().putBoolean(prefKey, isChecked).apply() - }, - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.internal_dictionary_summary), - color = color, - style = MaterialTheme.typography.titleSmall - ) - } - } else { - Text(stringResource(R.string.internal_dictionary_summary), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = bottomPadding), - color = color, - style = MaterialTheme.typography.titleSmall - ) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + Switch( + checked = enabled, + onCheckedChange = { isChecked -> + enabled = isChecked + prefs.edit().putBoolean(mainPrefKey, isChecked).apply() + }, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = stringResource(R.string.main_dictionary), + style = MaterialTheme.typography.titleMedium + ) } - if (mainDict != null) + + if (mainDict != null) { DictionaryDetails(mainDict) { refreshTrigger++ } + } if (addonDicts.isNotEmpty()) { HorizontalDivider() Text(stringResource(R.string.dictionary_category_title), @@ -129,7 +118,7 @@ fun DictionaryDialog( addonDicts.forEach { DictionaryDetails(it) { refreshTrigger++ } } } val knownDicts = remember { - if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") { + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard" || helium314.keyboard.latin.BuildConfig.FLAVOR == "standardfull") { helium314.keyboard.latin.utils.getKnownDictionariesForLocale(locale, ctx) } else emptyList() } @@ -172,15 +161,16 @@ fun DictionaryDialog( private fun DictionaryDetails(dict: File, onDelete: () -> Unit) { val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(dict) ?: return val type = header.mIdString.substringBefore(":") - var showDeleteDialog by remember { mutableStateOf(false) } - var showDetails by remember { mutableStateOf(false) } - val title = if (type != DictionaryInfoUtils.DEFAULT_MAIN_DICT) type - else stringResource(R.string.main_dictionary) val ctx = LocalContext.current val prefs = ctx.prefs() val prefKey = "pref_dict_enabled_${header.mIdString}" var enabled by remember { mutableStateOf(prefs.getBoolean(prefKey, true)) } - + var showDetails by remember { mutableStateOf(false) } + val title = when (type) { + DictionaryInfoUtils.DEFAULT_MAIN_DICT -> stringResource(R.string.main_dictionary) + Dictionary.TYPE_EMOJI -> stringResource(R.string.subtype_emoji) + else -> type + } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -195,10 +185,8 @@ private fun DictionaryDetails(dict: File, onDelete: () -> Unit) { modifier = Modifier.padding(end = 8.dp) ) Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) - DeleteButton { showDeleteDialog = true } ExpandButton { showDetails = !showDetails } } - // default animations look better but make the dialog flash, see also MultiSliderPreference AnimatedVisibility(showDetails, enter = fadeIn(), exit = fadeOut()) { Text( header.info(LocalConfiguration.current.locale()), @@ -206,16 +194,7 @@ private fun DictionaryDetails(dict: File, onDelete: () -> Unit) { modifier = Modifier.padding(start = 10.dp, top = 0.dp, end = 10.dp, bottom = 12.dp) ) } - if (showDeleteDialog) - ConfirmationDialog( - onDismissRequest = { showDeleteDialog = false }, - confirmButtonText = stringResource(R.string.remove), - onConfirmed = { - dict.delete() - onDelete() - }, - content = { Text(stringResource(R.string.remove_dictionary_message, type))} - ) + } 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 e1df6b1e0..b78b0e90b 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") { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardfull") { 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 c5db7023b..372ea599f 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 == "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 == "standardfull" || 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 1f1a27a77..a51cc450d 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -3,12 +3,22 @@ package helium314.keyboard.settings.screens import android.content.Context import android.content.Intent +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -20,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource @@ -84,94 +95,102 @@ fun DictionaryScreen( }, itemContent = { locale -> if (locale.language == SubtypeLocaleUtils.NO_LANGUAGE) { - // Add Dictionary Entry - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + // Card for general actions + Card( modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) .fillMaxWidth() - .clickable { showAddDictDialog = true } + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + shape = RoundedCornerShape(16.dp) ) { - Text( - stringResource(R.string.add_new_dictionary_title), - ) - Icon(painterResource(R.drawable.ic_plus), stringResource(R.string.add_new_dictionary_title)) - } - androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) - - // Personal Dictionary Entry - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) - .fillMaxWidth() - .clickable { SettingsDestination.navigateTo(SettingsDestination.PersonalDictionaries) } - ) { - Text( - stringResource(R.string.edit_personal_dictionary), - style = MaterialTheme.typography.titleMedium - ) - NextScreenIcon() - } - androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + Column(modifier = Modifier.padding(vertical = 4.dp)) { + // Add Dictionary Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { showAddDictDialog = true } + .padding(vertical = 14.dp, horizontal = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 12.dp).size(24.dp) + ) + Text( + stringResource(R.string.add_new_dictionary_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + NextScreenIcon() + } - // 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)) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) - // 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)) + // Personal Dictionary Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.PersonalDictionaries) } + .padding(vertical = 14.dp, horizontal = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(R.drawable.ic_dictionary), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 12.dp).size(24.dp) + ) + Text( + stringResource(R.string.edit_personal_dictionary), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + NextScreenIcon() + } - // Blocklist Entry - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) - .fillMaxWidth() - .clickable { SettingsDestination.navigateTo(SettingsDestination.Blocklist) } - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(R.string.blocklist), - style = MaterialTheme.typography.titleMedium - ) - Text( - stringResource(R.string.blocklist_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant ) + + // Blocked Words Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) } + .padding(vertical = 14.dp, horizontal = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(R.drawable.ic_bin), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 12.dp).size(24.dp) + ) + Text( + stringResource(R.string.edit_blocked_words), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + NextScreenIcon() + } } - NextScreenIcon() } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) @@ -244,58 +263,145 @@ fun DictionaryScreen( ) } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) - - // Personal Dictionary Setting + + // Card for Personal Dictionary Switch Setting val prefs = ctx.prefs() var personalDictEnabled by remember { mutableStateOf(prefs.getBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY, Defaults.PREF_ADD_TO_PERSONAL_DICTIONARY)) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Card( modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) .fillMaxWidth() - .clickable { - val newValue = !personalDictEnabled - personalDictEnabled = newValue - ctx.prefs().edit { putBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY, newValue) } - } + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + shape = RoundedCornerShape(16.dp) ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(R.string.add_to_personal_dictionary), - style = MaterialTheme.typography.titleMedium - ) - Text( - stringResource(R.string.add_to_personal_dictionary_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .clickable { + val newValue = !personalDictEnabled + personalDictEnabled = newValue + ctx.prefs().edit { putBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY, newValue) } + } + .padding(all = 16.dp) + .fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text( + stringResource(R.string.add_to_personal_dictionary), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + stringResource(R.string.add_to_personal_dictionary_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.Switch( + checked = personalDictEnabled, + onCheckedChange = { + personalDictEnabled = it + ctx.prefs().edit { putBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY, it) } + } ) } - androidx.compose.material3.Switch( - checked = personalDictEnabled, - onCheckedChange = { - personalDictEnabled = it - ctx.prefs().edit { putBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY, it) } - } - ) } + + // Add a "Languages" Section Header + Text( + text = stringResource(R.string.language_and_layouts_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + modifier = Modifier.padding(start = 24.dp, top = 20.dp, bottom = 8.dp) + ) } else { - Column( - Modifier - .clickable { selectedLocale = locale } - .padding(vertical = 6.dp, horizontal = 16.dp) + // Premium Language Card + Card( + modifier = Modifier .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .clickable { selectedLocale = locale }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(12.dp) ) { - val (dicts, hasInternal) = getUserAndInternalDictionaries(ctx, locale) - val types = dicts.mapTo(mutableListOf()) { it.name.substringBefore("_${DictionaryInfoUtils.USER_DICTIONARY_SUFFIX}") } - if (hasInternal && !types.contains(Dictionary.TYPE_MAIN)) - types.add(0, stringResource(R.string.internal_dictionary_summary)) - Text(locale.localizedDisplayName(LocalResources.current)) - Text( - types.joinToString(", "), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = locale.localizedDisplayName(LocalResources.current), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + + val (dicts, hasInternal) = getUserAndInternalDictionaries(ctx, locale) + val mainDictLabel = stringResource(R.string.main_dictionary) + val internalDictLabel = stringResource(R.string.internal_dictionary_summary) + val types = dicts.mapTo(mutableListOf()) { file -> + if (file.name == DictionaryInfoUtils.MAIN_DICT_FILE_NAME) { + mainDictLabel + } else { + file.name.substringBefore("_${DictionaryInfoUtils.USER_DICTIONARY_SUFFIX}") + } + } + if (hasInternal && !types.contains(Dictionary.TYPE_MAIN) && !types.contains(mainDictLabel)) + types.add(0, internalDictLabel) + + // Render active dictionaries as stylized badges + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (types.isEmpty()) { + Text( + text = "No active dictionaries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } else { + types.forEach { type -> + val badgeColor = when (type.lowercase()) { + "main", internalDictLabel.lowercase(), mainDictLabel.lowercase() -> MaterialTheme.colorScheme.primaryContainer + "user" -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.tertiaryContainer + } + val badgeTextColor = when (type.lowercase()) { + "main", internalDictLabel.lowercase(), mainDictLabel.lowercase() -> MaterialTheme.colorScheme.onPrimaryContainer + "user" -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onTertiaryContainer + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(badgeColor) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = type, + style = MaterialTheme.typography.labelMedium, + color = badgeTextColor, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold + ) + } + } + } + } + } + NextScreenIcon() + } } } } @@ -325,26 +431,46 @@ fun DictionaryScreen( } } -/** @return list of user dictionary files and whether an internal dictionary exists */ fun getUserAndInternalDictionaries(context: Context, locale: Locale): Pair, Boolean> { val userDicts = mutableListOf() var hasInternalDict = false - val userLocaleDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) } + + var userLocaleDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) } + var hasFiles = userLocaleDir?.exists() == true && userLocaleDir.isDirectory && userLocaleDir.listFiles()?.any { + it.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX) || it.name.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX) || it.name.endsWith(".dict") + } == true + + if (!hasFiles && (locale.country.isNotEmpty() || locale.variant.isNotEmpty())) { + val fallbackLocale = Locale(locale.language) + val fallbackDir = DictionaryInfoUtils.getCacheDirectoryForLocale(fallbackLocale, context)?.let { File(it) } + val hasFallbackFiles = fallbackDir?.exists() == true && fallbackDir.isDirectory && fallbackDir.listFiles()?.any { + it.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX) || it.name.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX) || it.name.endsWith(".dict") + } == true + if (hasFallbackFiles) { + userLocaleDir = fallbackDir + } + } + if (userLocaleDir?.exists() == true && userLocaleDir.isDirectory) { userLocaleDir.listFiles()?.forEach { - if (it.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX)) + if (it.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX)) { userDicts.add(it) - else if (it.name.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX)) + } else if (it.name.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX)) { hasInternalDict = true + } else if (it.name.endsWith(".dict")) { + userDicts.add(it) + } } } - if (hasInternalDict) - return userDicts to true - val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(context) ?: return userDicts to false - val best = LocaleUtils.getBestMatch(locale, internalDicts.toList()) { - DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(it) + val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(context) + val best = internalDicts?.let { + LocaleUtils.getBestMatch(locale, it.toList()) { dict -> + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict) + } } - return userDicts to (best != null) + val hasAsset = best != null + + return userDicts to (hasInternalDict || hasAsset) } @Preview diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index bb9d310e0..cd5c41687 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -40,7 +40,7 @@ fun GestureTypingScreen( val hasGestureLib = JniUtils.sHaveGestureLib val gestureFloatingPreviewEnabled = prefs.getBoolean(Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT, Defaults.PREF_GESTURE_FLOATING_PREVIEW_TEXT) val gestureEnabled = hasGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT) - + // Always show library loader first when no library val items = buildList { add(R.string.settings_category_configuration) @@ -71,14 +71,18 @@ fun GestureTypingScreen( add(R.string.settings_category_gestures_advanced) add(Settings.PREF_SPACE_HORIZONTAL_SWIPE) add(Settings.PREF_SPACE_VERTICAL_SWIPE) - add(Settings.PREF_TOUCHPAD_SENSITIVITY) - add(Settings.PREF_TOUCHPAD_EDGE_SCROLL) + add(Settings.PREF_DELETE_SWIPE) add(Settings.PREF_SHORTCUT_ROWS) if (prefs.getBoolean(Settings.PREF_SHORTCUT_ROWS, Defaults.PREF_SHORTCUT_ROWS)) { add(Settings.PREF_SHORTCUT_TOP_ROW) add(Settings.PREF_SHORTCUT_BOTTOM_ROW) } + + add(R.string.settings_category_touchpad) + add(Settings.PREF_TOUCHPAD_SENSITIVITY) + add(Settings.PREF_TOUCHPAD_EDGE_SCROLL) + add(Settings.PREF_TOUCHPAD_FULLSCREEN) } SearchSettingsScreen( onClickBack = onClickBack, @@ -170,6 +174,9 @@ fun createGestureTypingSettings(context: Context) = listOf( description = { value -> value.toInt().toString() } ) }, + Setting(context, Settings.PREF_TOUCHPAD_FULLSCREEN, R.string.touchpad_fullscreen, R.string.touchpad_fullscreen_summary) { + SwitchPreference(it, Defaults.PREF_TOUCHPAD_FULLSCREEN) + }, Setting(context, Settings.PREF_TOUCHPAD_EDGE_SCROLL, R.string.touchpad_edge_scroll, R.string.touchpad_edge_scroll_summary) { SwitchPreference(it, Defaults.PREF_TOUCHPAD_EDGE_SCROLL) }, 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 b55410549..80c771936 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -95,6 +95,17 @@ fun LibrariesHubScreen( icon = R.drawable.ic_emoji_smileys_emotion ) + // Handwriting Input Plugin + if (BuildConfig.FLAVOR == "standardfull") { + var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } + LoadHandwritingPluginPreference( + title = stringResource(R.string.libraries_hub_handwriting_title), + summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), + icon = R.drawable.ic_edit, + onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } + ) + } + // Handwriting Input Plugin if (BuildConfig.FLAVOR == "standard") { var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 5896fdd11..7c294db68 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -71,10 +71,6 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { val context = LocalContext.current val prefs = context.prefs() - var prefixText by remember { - mutableStateOf(TextExpanderUtils.getPrefix(context)) - } - var isExpanderEnabled by remember { mutableStateOf(TextExpanderUtils.isEnabled(context)) } @@ -90,6 +86,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var isGuideExpanded by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) } + var editingPrefix by remember { mutableStateOf("") } var editingShortcut by remember { mutableStateOf("") } var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } @@ -108,25 +105,27 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { }, filteredItems = { term -> shortcutsMap.entries - .filter { (shortcut, template) -> - val displayShortcut = if (shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX)) { - shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) - } else shortcut + .filter { (shortcut, entry) -> + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + val cleanKey = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + val rawShortcut = cleanKey.substring(entry.prefix.length) + val displayShortcut = entry.prefix + rawShortcut displayShortcut.contains(term, ignoreCase = true) || - template.contains(term, ignoreCase = true) + entry.template.contains(term, ignoreCase = true) } .map { Pair(it.key, it.value) } }, - itemContent = { (shortcut, template) -> + itemContent = { (shortcut, entry) -> Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) { ShortcutItem( shortcut = shortcut, - template = template, - prefix = prefixText, + entry = entry, onEdit = { val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) - editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut - editingTemplate = TextFieldValue(template) + val cleanKey = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + editingPrefix = entry.prefix + editingShortcut = cleanKey.substring(entry.prefix.length) + editingTemplate = TextFieldValue(entry.template) originalShortcutToEdit = shortcut editingIsRegex = isRegex showAddDialog = true @@ -212,13 +211,13 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { StepBadge(num = "1") Column(modifier = Modifier.weight(1f)) { Text( - text = "Set a Shortcut Prefix", + text = "Specify Shortcut Prefix", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Choose a prefix like '.' or ';' under prefix configuration to prevent accidental expansions.", + text = "Configure an optional prefix like '.' or ';' per shortcut on the edit screen to prevent accidental expansions.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -324,20 +323,17 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { onCheckedChange = { isImmediateEnabled = it } ) - // 2. Custom Prefix Configuration - OutlinedTextField( - value = prefixText, - onValueChange = { - prefixText = it - prefs.edit { putString(TextExpanderUtils.PREF_PREFIX, it) } - }, - label = { Text("Shortcut Prefix (e.g. '..', '.', ';', or blank)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - enabled = isExpanderEnabled + SwitchPreference( + name = "Expand immediately", + key = TextExpanderUtils.PREF_IMMEDIATE, + default = false, + description = "Expand shortcuts immediately without pressing space.", + enabled = isExpanderEnabled, + onCheckedChange = { isImmediateEnabled = it } ) + // global prefix config removed + // 3. Section Title / Header for shortcuts Text( text = "Custom Shortcuts", @@ -395,15 +391,16 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { } } } else { - shortcutsMap.forEach { (shortcut, template) -> + shortcutsMap.forEach { (shortcut, entry) -> ShortcutItem( shortcut = shortcut, - template = template, - prefix = prefixText, + entry = entry, onEdit = { val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) - editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut - editingTemplate = TextFieldValue(template) + val cleanKey = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + editingPrefix = entry.prefix + editingShortcut = cleanKey.substring(entry.prefix.length) + editingTemplate = TextFieldValue(entry.template) originalShortcutToEdit = shortcut editingIsRegex = isRegex showAddDialog = true @@ -459,16 +456,16 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { updated.remove(originalShortcutToEdit) } val key = if (editingIsRegex) { - TextExpanderUtils.REGEX_PREFIX + editingShortcut.trim() + TextExpanderUtils.REGEX_PREFIX + editingPrefix.trim() + editingShortcut.trim() } else { - editingShortcut.trim() + editingPrefix.trim() + editingShortcut.trim() } - updated[key] = editingTemplate.text + updated[key] = TextExpanderUtils.ShortcutEntry(editingTemplate.text, editingPrefix.trim()) shortcutsMap = updated TextExpanderUtils.saveShortcuts(context, updated) showAddDialog = false }, - checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() && isRegexValid }, + checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() && isRegexValid }, confirmButtonText = if (isEditMode) "Save" else "Add", neutralButtonText = if (isEditMode) "Delete" else null, onNeutral = { @@ -488,15 +485,25 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { focusRequester.requestFocus() } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - TextField( - value = editingShortcut, - onValueChange = { editingShortcut = if (editingIsRegex) it else it.replace(" ", "") }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - singleLine = true, - label = { Text(if (editingIsRegex) "Regex Pattern (e.g. '(\\d+)usd')" else "Shortcut (e.g. 'brb', 'em')") } - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = editingPrefix, + onValueChange = { editingPrefix = it.replace(" ", "") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Prefix (optional)") } + ) + TextField( + value = editingShortcut, + onValueChange = { editingShortcut = if (editingIsRegex) it else it.replace(" ", "") }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + singleLine = true, + label = { Text(if (editingIsRegex) "Regex Pattern" else "Shortcut (e.g. 'brb')") } + ) + } Row( modifier = Modifier.fillMaxWidth(), @@ -591,14 +598,14 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { @Composable private fun ShortcutItem( shortcut: String, - template: String, - prefix: String, + entry: TextExpanderUtils.ShortcutEntry, onEdit: () -> Unit, onDelete: () -> Unit ) { val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) - val displayShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut - + val cleanKey = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + val rawShortcut = cleanKey.substring(entry.prefix.length) + val displayShortcut = entry.prefix + rawShortcut ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -628,7 +635,7 @@ private fun ShortcutItem( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - text = if (isRegex) displayShortcut else "$prefix$displayShortcut", + text = displayShortcut, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -653,11 +660,11 @@ private fun ShortcutItem( } Spacer(modifier = Modifier.height(8.dp)) Text( - text = template, + text = entry.template, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, - fontFamily = if (template.contains("%")) androidx.compose.ui.text.font.FontFamily.Monospace else null + fontFamily = if (entry.template.contains("%")) androidx.compose.ui.text.font.FontFamily.Monospace else null ) } 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 29c6e31f6..e57298f1f 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 == "offline" - lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardfull" || BuildConfig.FLAVOR == "offline" + lowerName == "handwriting" -> BuildConfig.FLAVOR == "standardfull" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/app/src/main/res/drawable/ic_text_edit.xml b/app/src/main/res/drawable/ic_text_edit.xml new file mode 100644 index 000000000..0b0ab3b28 --- /dev/null +++ b/app/src/main/res/drawable/ic_text_edit.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/main_keyboard_frame.xml b/app/src/main/res/layout/main_keyboard_frame.xml index 5ce124c2e..2123eeed6 100644 --- a/app/src/main/res/layout/main_keyboard_frame.xml +++ b/app/src/main/res/layout/main_keyboard_frame.xml @@ -43,6 +43,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/touchpad_view.xml b/app/src/main/res/layout/touchpad_view.xml index 93b67be31..de0ffe6de 100644 --- a/app/src/main/res/layout/touchpad_view.xml +++ b/app/src/main/res/layout/touchpad_view.xml @@ -3,7 +3,9 @@ Touchpad mode overlay layout - replaces keyboard with a laptop-style touchpad SPDX-License-Identifier: GPL-3.0-only --> - + - + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c60a7a7a4..6f5aa12ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,8 @@ Behavior Advanced Gestures + + Touchpad Enable split keyboard @@ -503,6 +505,9 @@ Join next Force next space Undo word + Text editing + Full-screen touchpad + Hide suggestion strip and toolbar when touchpad mode is active Disable learning of new words 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 4e5b178e6..992920ac3 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -44,160 +44,160 @@ class ProofreadService(private val context: Context) { // 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 - } - - 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 +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 } - @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 + unloadJob = scope.launch { + delay(UNLOAD_DELAY_MS) + unloadModel() + Log.i(TAG, "Offline AI model unloaded due to inactivity") } +} - 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 - } - - 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() +@Synchronized +fun cancelUnload() { + unloadJob?.cancel() + unloadJob = null +} - // 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 +@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 +} - Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") +suspend fun loadModel( + context: Context, + modelPath: String +): Boolean = loadMutex.withLock { + cancelUnload() - // 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 - ) + // Check if already loaded with same path + if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { + return true + } - // 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) + 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) + 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) - } + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) + } catch (e: Throwable) { + Log.e(TAG, "Error in native token callback", e) } + } - // Start the engine - val result = llama.startEngine(params, callback) + // Start the engine + val result = llama.startEngine(params, callback) - val contextId = result?.get("contextId") as? Int - ?: throw IllegalStateException("contextId not found in result map") + 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) + // 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)) + // 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 - } + 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" +private const val TAG = "LlamaProofreadService" } // AI Provider support (API compatibility) 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 062d2dc46..37b43f6c4 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -19,6 +19,8 @@ class ProofreadService(private val context: Context) { + + 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 010ba170f..54ced1e37 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -65,9 +65,9 @@ class ProofreadService(private val context: Context) { fun getProvider(): AIProvider { val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) return try { - AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) +AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) } catch (e: IllegalArgumentException) { - AIProvider.GEMINI +AIProvider.GEMINI } } diff --git a/app/src/standardfull/AndroidManifest.xml b/app/src/standardfull/AndroidManifest.xml new file mode 100644 index 000000000..90802b351 --- /dev/null +++ b/app/src/standardfull/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/docs/badges/download.svg b/docs/badges/download.svg new file mode 100644 index 000000000..cb82e0b80 --- /dev/null +++ b/docs/badges/download.svg @@ -0,0 +1 @@ +VersionVersionv3.8.9v3.8.9 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg new file mode 100644 index 000000000..720b811be --- /dev/null +++ b/docs/badges/downloads.svg @@ -0,0 +1 @@ +DownloadsDownloads3350333503 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg new file mode 100644 index 000000000..74a51bf4d --- /dev/null +++ b/docs/badges/stars.svg @@ -0,0 +1 @@ +StarsStars502502 diff --git a/docs/releasenote/release_notes_v3.8.9.md b/docs/releasenote/release_notes_v3.8.9.md new file mode 100644 index 000000000..dfa8a5003 --- /dev/null +++ b/docs/releasenote/release_notes_v3.8.9.md @@ -0,0 +1,39 @@ +### 💖 Support Our Work +* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters! + +## 🚀 What's New + +### 🖱️ Touchpad & Text Editing Mode +- **Gboard-style Text Editing Mode**: Added a dedicated cursor navigation and editing overlay. Activated from the toolbar, it offers precise DPAD navigation, custom selection mode (Shift + arrows), clipboard actions (Select All, Copy, Cut, Paste), and editing utilities (Backspace, Forward Delete). +- **Dedicated Touchpad settings section**: Extracted Touchpad configuration from Advanced Gestures into its own dedicated settings category page. +- **Full-screen Touchpad**: Added a preference to automatically hide the suggestion strip and toolbar when Touchpad mode is active, maximizing the touchpad surface area. +- **Touchpad Close Button**: Added an overlay close button at the bottom-right corner of the touchpad, styled dynamically according to the active keyboard theme, for quick exit. + +### 📋 Clipboard & Screenshot Suggestions +- **Persistent Suggestions Dismissal**: The keyboard now remembers if you dismissed a clipboard text or screenshot suggestion, preventing it from showing up again even across keyboard restarts. The dismissed state automatically resets when a new item is copied. +- **Reduced Suggestion Timeouts**: Reduced clipboard and screenshot suggestion timeouts to 1 minute to keep suggestions fresh. + +### 🔤 Text Expander Improvements +- **Shortcut Prefix Fix**: Resolved a prefix matching bug where custom prefixes (e.g. `*` for shortcut `g`) were prepended twice (matching `**g` instead of `*g`), restoring instant and spacebar text expansion. +- **Improved Dialog Layout**: Stacked the Prefix and Shortcut input fields vertically inside the Add/Edit Shortcut dialog for better legibility. + +### 📚 Dictionary & Settings Refinement +- **Polished Dictionary Settings UI**: Re-designed the layout using modern Material 3 Card components, colorful badges/chips for dictionary types, and uniform utility icon sizes. +- **Smart Language Fallback**: Regional variants (e.g., `ml-IN`) will now fall back to the general parent language dictionary (e.g., `ml`) if country-specific files are missing, ensuring seamless next-word predictions. +- **Unified Dictionary Toggling**: Grouped all dictionary subtypes (including downloaded emoji dictionaries) under a single master toggle per language card, and removed redundant buttons. +- **Aesthetic Filtering**: Hidden non-enabled regional variant cards from the settings menu to reduce clutter, while keeping them configurable inside the active language dialog. +- **Download Path Collision Fix**: Extracted specific locales from download URLs to prevent file collision and duplicate settings cards. + +### ⚙️ Engine & Compatibility +- **Build Flavor Split**: Split standard build into FOSS-compliant "standard" (no ML Kit, F-Droid ready) and "standardfull" (includes ML Kit/Handwriting). +- **Secure Signing Workflow**: Added secure CI/CD release build signing workflow for reproducible builds. +- **ABI & Theme Fixes**: Re-added `armeabi-v7a` support to ABI filters, fixed popup key contrast-aware colors on AMOLED black theme, and improved split mode suggestions. + +## 📦 Downloads (Choose Your Flavor) + +| File | Description | Permissions | +| :--- | :--- | :--- | +| **`1-LeanType_3.8.9-standardfull-release.apk`** | **Recommended**. Cloud AI | Internet | +| **`2-LeanType_3.8.9-standard-release.apk`** | **Fdroid Build**. Standard + No Handwrite | Internet | +| **`3-LeanType_3.8.9-offline-release.apk`** | **Privacy Focused**. No Internet. Offline AI Only. | No Internet | +| **`4-LeanType_3.8.9-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI code. | No Internet | diff --git a/docs/scripts/generate_release_notes.py b/docs/scripts/generate_release_notes.py new file mode 100755 index 000000000..94cc57680 --- /dev/null +++ b/docs/scripts/generate_release_notes.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import os +import re + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(os.path.dirname(script_dir)) + + # 1. Try to get version from tag name (if running in GitHub Actions) + ref_name = os.environ.get('GITHUB_REF_NAME') + version_name = None + if ref_name and ref_name.startswith('v'): + version_name = ref_name[1:] + print(f"Detected version name from GITHUB_REF_NAME: {version_name}") + + # 2. Fall back to build.gradle.kts if not running in action or tag not matching + if not version_name: + gradle_path = os.path.join(project_root, 'app', 'build.gradle.kts') + if os.path.exists(gradle_path): + with open(gradle_path, 'r', encoding='utf-8') as f: + gradle_content = f.read() + version_name_match = re.search(r'versionName\s*=\s*"([^"]+)"', gradle_content) + if version_name_match: + version_name = version_name_match.group(1) + print(f"Parsed version name from build.gradle.kts: {version_name}") + + if not version_name: + print("Error: Could not determine version name") + return + + # 3. Locate the existing release notes file + releasenote_dir = os.path.join(project_root, 'docs', 'releasenote') + source_path = os.path.join(releasenote_dir, f'release_notes_v{version_name}.md') + temp_path = os.path.join(releasenote_dir, 'release_notes_temp.md') + + if not os.path.exists(source_path): + print(f"Error: Release note file {source_path} not found") + # Write a fallback file so the build/release step doesn't fail + with open(temp_path, 'w', encoding='utf-8') as f: + f.write(f"Release notes for version {version_name}") + return + + # 4. Copy to release_notes_temp.md + with open(source_path, 'r', encoding='utf-8') as sf: + content = sf.read() + + with open(temp_path, 'w', encoding='utf-8') as df: + df.write(content) + + print(f"Successfully copied {source_path} to {temp_path}") + +if __name__ == '__main__': + # ponytail: copy pre-generated release notes file to temp.md + main() diff --git a/fastlane/metadata/android/en-US/changelogs/3890.txt b/fastlane/metadata/android/en-US/changelogs/3890.txt new file mode 100644 index 000000000..dacad3b08 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3890.txt @@ -0,0 +1,7 @@ +- Gboard-style Text Editing panel with cursor & selection controls +- Dedicated Touchpad settings, Full-screen mode & Close button +- Persistent clipboard/screenshot dismiss & 1-minute timeout +- Text expander shortcut prefix fix & layout updates +- Polished Dictionary settings UI with Material 3 cards +- Prevent dictionary download path clashes & support fallback locales +- Unified dictionary toggles & regional cards filtering